diff --git a/app/build.gradle b/app/build.gradle index e2bb766be..02de497aa 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -108,7 +108,7 @@ ext.glideVersion = '4.13.1' ext.daggerVersion = '2.42' ext.materialdrawerVersion = '8.4.5' ext.emoji2_version = '1.1.0' -ext.filemojicompat_version = '3.2.1' +ext.filemojicompat_version = '3.2.2' repositories { maven { @@ -154,7 +154,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 "at.connyduck:networkresult-calladapter:1.0.0" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion" @@ -165,7 +165,7 @@ dependencies { implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion" kapt "com.github.bumptech.glide:compiler:$glideVersion" - implementation "com.github.penfeizhou.android.animation:glide-plugin:2.20.0" + implementation "com.github.penfeizhou.android.animation:glide-plugin:2.22.0" implementation "io.reactivex.rxjava3:rxjava:3.1.3" implementation "io.reactivex.rxjava3:rxandroid:3.0.0" @@ -188,7 +188,7 @@ dependencies { implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion" implementation 'com.mikepenz:google-material-typeface:4.0.0.2-kotlin@aar' - implementation "com.github.CanHub:Android-Image-Cropper:4.1.0" + implementation "com.github.CanHub:Android-Image-Cropper:4.2.1" implementation "de.c1710:filemojicompat-ui:$filemojicompat_version" implementation "de.c1710:filemojicompat:$filemojicompat_version" @@ -202,6 +202,8 @@ dependencies { testImplementation "org.mockito:mockito-inline:4.4.0" testImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0" + testImplementation "com.squareup.okhttp3:mockwebserver:$okhttpVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0" androidTestImplementation "androidx.room:room-testing:$roomVersion" androidTestImplementation "androidx.test.ext:junit:1.1.3" diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/37.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/37.json new file mode 100644 index 000000000..8d748248c --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/37.json @@ -0,0 +1,869 @@ +{ + "formatVersion": 1, + "database": { + "version": 37, + "identityHash": "11033751d382aa8a1c6fc68833097d35", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` 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": "repliesCount", + "columnName": "repliesCount", + "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_repliesCount` 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.repliesCount", + "columnName": "s_repliesCount", + "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, '11033751d382aa8a1c6fc68833097d35')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/38.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/38.json new file mode 100644 index 000000000..391d6b862 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/38.json @@ -0,0 +1,875 @@ +{ + "formatVersion": 1, + "database": { + "version": 38, + "identityHash": "798fc8d34064eb671c079689d4650ea5", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` 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": "repliesCount", + "columnName": "repliesCount", + "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, `order` INTEGER 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_repliesCount` 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": "order", + "columnName": "order", + "affinity": "INTEGER", + "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.repliesCount", + "columnName": "s_repliesCount", + "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, '798fc8d34064eb671c079689d4650ea5')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/39.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/39.json new file mode 100644 index 000000000..23ae0df2c --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/39.json @@ -0,0 +1,893 @@ +{ + "formatVersion": 1, + "database": { + "version": 39, + "identityHash": "bf4c9d8417b71e549170a568522d513d", + "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, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `quote` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER 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_repliesCount` 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": "order", + "columnName": "order", + "affinity": "INTEGER", + "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.repliesCount", + "columnName": "s_repliesCount", + "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, 'bf4c9d8417b71e549170a568522d513d')" + ] + } +} \ 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 dadf5f4d5..ce4d23d16 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -49,6 +49,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.viewpager2.widget.MarginPageTransformer +import at.connyduck.calladapter.networkresult.fold import autodispose2.androidx.lifecycle.autoDispose import com.bumptech.glide.Glide import com.bumptech.glide.RequestManager @@ -70,13 +71,10 @@ import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType -import com.keylesspalace.tusky.components.conversation.ConversationsRepository -import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.drafts.DraftsActivity import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.components.notifications.disableAllNotifications -import com.keylesspalace.tusky.components.notifications.disableUnifiedPushNotificationsForAccount import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary import com.keylesspalace.tusky.components.preference.PreferencesActivity @@ -94,11 +92,12 @@ import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.ResettableFragment import com.keylesspalace.tusky.pager.MainPagerAdapter import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.usecase.LogoutUsecase import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.deleteStaleCachedMedia import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.hide -import com.keylesspalace.tusky.util.removeShortcut +import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.updateShortcut import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible @@ -153,10 +152,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje lateinit var cacheUpdater: CacheUpdater @Inject - lateinit var conversationRepository: ConversationsRepository - - @Inject - lateinit var draftHelper: DraftHelper + lateinit var logoutUsecase: LogoutUsecase @Inject lateinit var viewModelFactory: ViewModelFactory @@ -834,28 +830,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje .setTitle(R.string.action_logout) .setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName)) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + binding.appBar.hide() + binding.viewPager.hide() + binding.progressBar.show() + binding.bottomNav.hide() + binding.composeButton.hide() + lifecycleScope.launch { - // Only disable UnifiedPush for this account -- do not call disableNotifications(), - // which unnecessarily disables it for all accounts and then re-enables it again at - // the next launch - disableUnifiedPushNotificationsForAccount(this@MainActivity, activeAccount) - NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this@MainActivity) - cacheUpdater.clearForUser(activeAccount.id) - conversationRepository.deleteCacheForAccount(activeAccount.id) - draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id) - removeShortcut(this@MainActivity, activeAccount) - val newAccount = accountManager.logActiveAccountOut() - if (!NotificationHelper.areNotificationsEnabled( - this@MainActivity, - accountManager - ) - ) { - NotificationHelper.disablePullNotifications(this@MainActivity) - } - val intent = if (newAccount == null) { - LoginActivity.getIntent(this@MainActivity, LoginActivity.MODE_DEFAULT) - } else { + val otherAccountAvailable = logoutUsecase.logout() + val intent = if (otherAccountAvailable) { Intent(this@MainActivity, MainActivity::class.java) + } else { + LoginActivity.getIntent(this@MainActivity, LoginActivity.MODE_DEFAULT) } startActivity(intent) finishWithoutSlideOutAnimation() @@ -888,7 +874,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this) // Setup push notifications - showMigrationNoticeIfNecessary(this, binding.root, accountManager) + showMigrationNoticeIfNecessary(this, binding.mainCoordinatorLayout, binding.composeButton, accountManager) if (NotificationHelper.areNotificationsEnabled(this, accountManager)) { lifecycleScope.launch { enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager) diff --git a/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt index 5e7ba616d..c014600d3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt @@ -20,7 +20,6 @@ import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import com.keylesspalace.tusky.components.login.LoginActivity -import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable import net.accelf.yuito.CustomUncaughtExceptionHandler @@ -37,12 +36,8 @@ class SplashActivity : AppCompatActivity(), Injectable { Thread.setDefaultUncaughtExceptionHandler(CustomUncaughtExceptionHandler(applicationContext)) - /** delete old notification channels */ - NotificationHelper.deleteLegacyNotificationChannels(this, accountManager) - /** Determine whether the user is currently logged in, and if so go ahead and load the * timeline. Otherwise, start the activity_login screen. */ - val intent = if (accountManager.activeAccount != null) { Intent(this, MainActivity::class.java) } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index 486869d20..5e9eac69b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -31,6 +31,7 @@ import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.transition.TransitionManager +import at.connyduck.calladapter.networkresult.fold import at.connyduck.sparkbutton.helpers.Utils import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from import autodispose2.autoDispose diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt index 4f58b1ffd..0b0115c2a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt @@ -44,6 +44,7 @@ class AccountSelectionAdapter(context: Context) : ArrayAdapter(co binding.username.text = account.fullName binding.displayName.text = account.displayName.emojify(account.emojis, binding.displayName, animateEmojis) + binding.avatarBadge.visibility = View.GONE // We never want to display the bot badge here val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp) val animateAvatar = pm.getBoolean("animateGifAvatars", false) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt deleted file mode 100644 index cf7559908..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* Copyright 2019 Conny Duck - * - * 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.adapter - -import androidx.paging.LoadState -import androidx.recyclerview.widget.RecyclerView -import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding -import com.keylesspalace.tusky.util.visible - -class NetworkStateViewHolder( - private val binding: ItemNetworkStateBinding, - private val retryCallback: () -> Unit -) : RecyclerView.ViewHolder(binding.root) { - - fun setUpWithNetworkState(state: LoadState) { - binding.progressBar.visible(state == LoadState.Loading) - binding.retryButton.visible(state is LoadState.Error) - val msg = if (state is LoadState.Error) { - state.error.message - } else { - null - } - binding.errorMsg.visible(msg != null) - binding.errorMsg.text = msg - binding.retryButton.setOnClickListener { - retryCallback() - } - } -} 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 fdffc1a50..74782e194 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -74,10 +74,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { public static class Key { public static final String KEY_CREATED = "created"; } - private TextView displayName; private TextView username; private ImageButton replyButton; + private TextView replyCountLabel; private SparkButton reblogButton; private SparkButton favouriteButton; private ImageButton quoteButton; @@ -129,6 +129,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { content = itemView.findViewById(R.id.status_content); avatar = itemView.findViewById(R.id.status_avatar); replyButton = itemView.findViewById(R.id.status_reply); + replyCountLabel = itemView.findViewById(R.id.status_replies); reblogButton = itemView.findViewById(R.id.status_inset); favouriteButton = itemView.findViewById(R.id.status_favourite); quoteButton = itemView.findViewById(R.id.status_quote); @@ -418,6 +419,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } + private void setReplyCount(int repliesCount) { + // This label only exists in the non-detailed view (to match the web ui) + if (replyCountLabel != null) { + replyCountLabel.setText((repliesCount > 1 ? replyCountLabel.getContext().getString(R.string.status_count_one_plus) : Integer.toString(repliesCount))); + } + } + private void setReblogged(boolean reblogged) { reblogButton.setChecked(reblogged); } @@ -841,6 +849,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions); setStatusVisibility(actionable.getVisibility()); setIsReply(actionable.getInReplyToId() != null); + setReplyCount(actionable.getRepliesCount()); setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(), actionable.getAccount().getBot(), statusDisplayOptions); setReblogged(actionable.getReblogged()); @@ -1147,6 +1156,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { actionable.getPoll() == null && card != null && !TextUtils.isEmpty(card.getUrl()) && + (!actionable.getSensitive() || status.isExpanded()) && (!status.isCollapsible() || !status.isCollapsed())) { cardView.setVisibility(View.VISIBLE); cardTitle.setText(card.getTitle()); 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 1a68af7e3..89dfbe986 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -107,18 +107,24 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder { @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 + // We never collapse statuses in the detail view + StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ? + status.copyWithCollapsed(false) : + status; + + super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads); + setupCard(uncollapsedStatus, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status if (payloads == null) { + Status actionable = uncollapsedStatus.getActionable(); if (!statusDisplayOptions.hideStats()) { - setReblogAndFavCount(status.getActionable().getReblogsCount(), - status.getActionable().getFavouritesCount(), listener); + setReblogAndFavCount(actionable.getReblogsCount(), + actionable.getFavouritesCount(), listener); } else { hideQuantitativeStats(); } - setApplication(status.getActionable().getApplication()); + setApplication(actionable.getApplication()); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt index 12cb4a69e..66ae898b6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt @@ -9,7 +9,7 @@ import javax.inject.Inject class CacheUpdater @Inject constructor( eventHub: EventHub, private val accountManager: AccountManager, - private val appDatabase: AppDatabase, + appDatabase: AppDatabase, gson: Gson ) { @@ -44,8 +44,4 @@ class CacheUpdater @Inject constructor( fun stop() { this.disposable.dispose() } - - suspend fun clearForUser(accountId: Long) { - appDatabase.timelineDao().removeAll(accountId) - } } 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 0934c48fc..c7e6781a1 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 @@ -20,6 +20,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.appstore.AnnouncementReadEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository 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 419a1a77f..59a5729b7 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 @@ -23,6 +23,7 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager +import android.graphics.Bitmap import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter import android.net.ConnectivityManager @@ -58,6 +59,9 @@ import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.transition.TransitionManager +import com.canhub.cropper.CropImage +import com.canhub.cropper.CropImageContract +import com.canhub.cropper.options import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity @@ -87,6 +91,7 @@ import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.afterTextChanged import com.keylesspalace.tusky.util.combineLiveData import com.keylesspalace.tusky.util.combineOptionalLiveData +import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.highlightSpans import com.keylesspalace.tusky.util.loadAvatar @@ -157,6 +162,32 @@ class ComposeActivity : } } + // Contract kicked off by editImageInQueue; expects viewModel.cropImageItemOld set + private val cropImage = registerForActivityResult(CropImageContract()) { result -> + val uriNew = result.uriContent + if (result.isSuccessful && uriNew != null) { + viewModel.cropImageItemOld?.let { itemOld -> + val size = getMediaSize(contentResolver, uriNew) + + lifecycleScope.launch { + viewModel.addMediaToQueue( + itemOld.type, + uriNew, + size, + itemOld.description, + itemOld + ) + } + } + } else if (result == CropImage.CancelledResult) { + Log.w("ComposeActivity", "Edit image cancelled by user") + } else { + Log.w("ComposeActivity", "Edit image failed: " + result.error) + displayTransientError(R.string.error_image_edit_failed) + } + viewModel.cropImageItemOld = null + } + public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -191,6 +222,7 @@ class ComposeActivity : viewModel.updateDescription(item.localId, newDescription) } }, + onEditImage = this::editImageInQueue, onRemove = this::removeMediaFromQueue ) binding.composeMediaPreviewBar.layoutManager = @@ -429,8 +461,13 @@ class ComposeActivity : enableButton(binding.composeAddMediaButton, active, active) enablePollButton(media.isNullOrEmpty()) }.subscribe() - viewModel.uploadError.observe { - displayTransientError(R.string.error_media_upload_sending) + viewModel.uploadError.observe { throwable -> + Log.w(TAG, "media upload failed", throwable) + if (throwable is UploadServerError) { + displayTransientError(throwable.errorMessage) + } else { + displayTransientError(R.string.error_media_upload_sending) + } } viewModel.setupComplete.observe { // Focus may have changed during view model setup, ensure initial focus is on the edit field @@ -594,19 +631,23 @@ class ComposeActivity : super.onSaveInstanceState(outState) } - private fun displayTransientError(@StringRes stringId: Int) { - val bar = Snackbar.make(binding.activityCompose, stringId, Snackbar.LENGTH_LONG) + private fun displayTransientError(errorMessage: String) { + val bar = Snackbar.make(binding.activityCompose, errorMessage, Snackbar.LENGTH_LONG) // necessary so snackbar is shown over everything bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) + bar.setAnchorView(R.id.composeBottomBar) bar.show() } + private fun displayTransientError(@StringRes stringId: Int) { + displayTransientError(getString(stringId)) + } private fun toggleHideMedia() { this.viewModel.toggleMarkSensitive() } private fun updateSensitiveMediaToggle(markMediaSensitive: Boolean, contentWarningShown: Boolean) { - if (viewModel.media.value.isNullOrEmpty()) { + if (viewModel.media.value.isEmpty()) { binding.composeHideMediaButton.hide() } else { binding.composeHideMediaButton.show() @@ -948,6 +989,26 @@ class ComposeActivity : binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN) } + private fun editImageInQueue(item: QueuedMedia) { + // If input image is lossless, output image should be lossless. + // Currently the only supported lossless format is png. + val mimeType: String? = contentResolver.getType(item.uri) + val isPng: Boolean = mimeType != null && mimeType.endsWith("/png") + val tempFile = createNewImageFile(this, if (isPng) ".png" else ".jpg") + + // "Authority" must be the same as the android:authorities string in AndroidManifest.xml + val uriNew = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID + ".fileprovider", tempFile) + + viewModel.cropImageItemOld = item + + cropImage.launch( + options(uri = item.uri) { + setOutputUri(uriNew) + setOutputCompressFormat(if (isPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG) + } + ) + } + private fun removeMediaFromQueue(item: QueuedMedia) { viewModel.removeMediaFromQueue(item) } 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 893f350bf..6eaf78a8d 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 @@ -23,6 +23,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult import com.keylesspalace.tusky.components.drafts.DraftHelper @@ -39,7 +40,6 @@ import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.StatusToSend import com.keylesspalace.tusky.util.combineLiveData import com.keylesspalace.tusky.util.randomAlphanumericString -import com.keylesspalace.tusky.util.result import com.keylesspalace.tusky.util.toLiveData import io.reactivex.rxjava3.core.Observable import kotlinx.coroutines.Dispatchers @@ -100,6 +100,9 @@ class ComposeViewModel @Inject constructor( private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty() + // Used in ComposeActivity to pass state to result function when cropImage contract inflight + var cropImageItemOld: QueuedMedia? = null + fun loadInstanceDataFromNetwork(loadActually: Boolean) { viewModelScope.launch { emoji.postValue(when (loadActually) { @@ -133,13 +136,16 @@ class ComposeViewModel @Inject constructor( } } - private suspend fun addMediaToQueue( + suspend fun addMediaToQueue( type: QueuedMedia.Type, uri: Uri, mediaSize: Long, - description: String? = null + description: String? = null, + replaceItem: QueuedMedia? = null ): QueuedMedia { - val mediaItem = media.updateAndGet { mediaValue -> + var stashMediaItem: QueuedMedia? = null + + media.updateAndGet { mediaValue -> val mediaItem = QueuedMedia( localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1, uri = uri, @@ -147,8 +153,19 @@ class ComposeViewModel @Inject constructor( mediaSize = mediaSize, description = description ) - mediaValue + mediaItem - }.last() + stashMediaItem = mediaItem + + if (replaceItem != null) { + mediaToJob[replaceItem.localId]?.cancel() + mediaValue.map { + if (it.localId == replaceItem.localId) mediaItem else it + } + } else { // Append + mediaValue + mediaItem + } + } + val mediaItem = stashMediaItem!! // stashMediaItem is always non-null and uncaptured at this point, but Kotlin doesn't know that + mediaToJob[mediaItem.localId] = viewModelScope.launch { mediaUploader .uploadMedia(mediaItem) @@ -213,7 +230,7 @@ class ComposeViewModel @Inject constructor( val contentWarningChanged = showContentWarning.value!! && !contentWarning.isNullOrEmpty() && !startingContentWarning.startsWith(contentWarning.toString()) - val mediaChanged = !media.value.isNullOrEmpty() + val mediaChanged = media.value.isNotEmpty() val pollChanged = poll.value != null return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged @@ -346,8 +363,7 @@ class ComposeViewModel @Inject constructor( fun searchAutocompleteSuggestions(token: String): List { when (token[0]) { '@' -> { - return api.searchAccountsCall(query = token.substring(1), limit = 10) - .result() + return api.searchAccountsSync(query = token.substring(1), limit = 10) .fold({ accounts -> accounts.map { AutocompleteResult.AccountResult(it) } }, { e -> @@ -356,8 +372,7 @@ class ComposeViewModel @Inject constructor( }) } '#' -> { - return api.searchCall(query = token, type = SearchType.Hashtag.apiParameter, limit = 10) - .result() + return api.searchSync(query = token, type = SearchType.Hashtag.apiParameter, limit = 10) .fold({ searchResult -> searchResult.hashtags.map { AutocompleteResult.HashtagResult(it.name) } }, { e -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt index 0b1fa8c41..be54a1aa9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt @@ -32,6 +32,7 @@ import com.keylesspalace.tusky.components.compose.view.ProgressImageView class MediaPreviewAdapter( context: Context, private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit, + private val onEditImage: (ComposeActivity.QueuedMedia) -> Unit, private val onRemove: (ComposeActivity.QueuedMedia) -> Unit ) : RecyclerView.Adapter() { @@ -43,12 +44,16 @@ class MediaPreviewAdapter( val item = differ.currentList[position] val popup = PopupMenu(view.context, view) val addCaptionId = 1 - val removeId = 2 + val editImageId = 2 + val removeId = 3 popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) + if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) + popup.menu.add(0, editImageId, 0, R.string.action_edit_image) popup.menu.add(0, removeId, 0, R.string.action_remove) popup.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { addCaptionId -> onAddCaption(item) + editImageId -> onEditImage(item) removeId -> onRemove(item) } 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 54bdf035d..a8a7c88f6 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 @@ -23,6 +23,7 @@ import android.util.Log import android.webkit.MimeTypeMap import androidx.core.content.FileProvider import androidx.core.net.toUri +import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia @@ -31,6 +32,7 @@ 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.getServerErrorMessage import com.keylesspalace.tusky.util.randomAlphanumericString import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -54,14 +56,14 @@ sealed class UploadEvent { data class FinishedEvent(val mediaId: String) : UploadEvent() } -fun createNewImageFile(context: Context): File { +fun createNewImageFile(context: Context, suffix: String = ".jpg"): File { // Create an image file name val randomId = randomAlphanumericString(12) val imageFileName = "Tusky_${randomId}_" val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) return File.createTempFile( imageFileName, /* prefix */ - ".jpg", /* suffix */ + suffix, /* suffix */ storageDir /* directory */ ) } @@ -72,6 +74,7 @@ class AudioSizeException : Exception() class VideoSizeException : Exception() class MediaTypeException : Exception() class CouldNotOpenFileException : Exception() +class UploadServerError(val errorMessage: String) : Exception() class MediaUploader @Inject constructor( private val context: Context, @@ -222,13 +225,21 @@ class MediaUploader @Inject constructor( null } - val result = mediaUploadApi.uploadMedia(body, description).getOrThrow() - if (media.uri.scheme == "file") { - media.uri.path?.let { - File(it).delete() + mediaUploadApi.uploadMedia(body, description).fold({ result -> + if (media.uri.scheme == "file") { + media.uri.path?.let { + File(it).delete() + } } - } - send(UploadEvent.FinishedEvent(result.id)) + send(UploadEvent.FinishedEvent(result.id)) + }, { throwable -> + val errorMessage = throwable.getServerErrorMessage() + if (errorMessage == null) { + throw throwable + } else { + throw UploadServerError(errorMessage) + } + }) awaitClose() } } @@ -245,7 +256,7 @@ class MediaUploader @Inject constructor( } private companion object { - private const val TAG = "MediaUploaderImpl" + private const val TAG = "MediaUploader" private const val STATUS_VIDEO_SIZE_LIMIT = 41943040 // 40MiB private const val STATUS_AUDIO_SIZE_LIMIT = 41943040 // 40MiB private const val STATUS_IMAGE_SIZE_LIMIT = 8388608 // 8MiB 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 0c9465142..a5a8ed27d 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 @@ -20,21 +20,40 @@ import android.view.ViewGroup import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions class ConversationAdapter( - private val statusDisplayOptions: StatusDisplayOptions, + private var statusDisplayOptions: StatusDisplayOptions, private val listener: StatusActionListener ) : PagingDataAdapter(CONVERSATION_COMPARATOR) { + var mediaPreviewEnabled: Boolean + get() = statusDisplayOptions.mediaPreviewEnabled + set(mediaPreviewEnabled) { + statusDisplayOptions = statusDisplayOptions.copy( + mediaPreviewEnabled = mediaPreviewEnabled + ) + } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder { val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false) return ConversationViewHolder(view, statusDisplayOptions, listener) } override fun onBindViewHolder(holder: ConversationViewHolder, position: Int) { - holder.setupWithConversation(getItem(position)) + onBindViewHolder(holder, position, emptyList()) + } + + override fun onBindViewHolder( + holder: ConversationViewHolder, + position: Int, + payloads: List + ) { + getItem(position)?.let { conversationViewData -> + holder.setupWithConversation(conversationViewData, payloads.firstOrNull()) + } } companion object { @@ -44,7 +63,17 @@ class ConversationAdapter( } override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean { - return oldItem == newItem + return false // Items are different always. It allows to refresh timestamp on every view holder update + } + + override fun getChangePayload(oldItem: ConversationViewData, newItem: ConversationViewData): Any? { + return if (oldItem == newItem) { + // If items are equal - update timestamp only + listOf(StatusBaseViewHolder.Key.KEY_CREATED) + } else { + // If items are different - update the whole view holder + null + } } } } 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 19e8ba041..beec1ba03 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 @@ -34,6 +34,7 @@ import java.util.Date data class ConversationEntity( val accountId: Long, val id: String, + val order: Int, val accounts: List, val unread: Boolean, @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity @@ -41,6 +42,7 @@ data class ConversationEntity( fun toViewData(): ConversationViewData { return ConversationViewData( id = id, + order = order, accounts = accounts, unread = unread, lastStatus = lastStatus.toViewData() @@ -50,6 +52,7 @@ data class ConversationEntity( data class ConversationAccountEntity( val id: String, + val localUsername: String, val username: String, val displayName: String, val avatar: String, @@ -58,12 +61,12 @@ data class ConversationAccountEntity( fun toAccount(): TimelineAccount { return TimelineAccount( id = id, + localUsername = localUsername, username = username, displayName = displayName, url = "", avatar = avatar, emojis = emojis, - localUsername = "", ) } } @@ -79,6 +82,7 @@ data class ConversationStatusEntity( val createdAt: Date, val emojis: List, val favouritesCount: Int, + val repliesCount: Int, val favourited: Boolean, val bookmarked: Boolean, val sensitive: Boolean, @@ -107,6 +111,7 @@ data class ConversationStatusEntity( emojis = emojis, reblogsCount = 0, favouritesCount = favouritesCount, + repliesCount = repliesCount, reblogged = false, favourited = favourited, bookmarked = bookmarked, @@ -133,6 +138,7 @@ data class ConversationStatusEntity( fun TimelineAccount.toEntity() = ConversationAccountEntity( id = id, + localUsername = localUsername, username = username, displayName = name, avatar = avatar, @@ -150,6 +156,7 @@ fun Status.toEntity() = createdAt = createdAt, emojis = emojis, favouritesCount = favouritesCount, + repliesCount = repliesCount, favourited = favourited, bookmarked = bookmarked, sensitive = sensitive, @@ -164,10 +171,11 @@ fun Status.toEntity() = poll = poll ) -fun Conversation.toEntity(accountId: Long) = +fun Conversation.toEntity(accountId: Long, order: Int) = ConversationEntity( accountId = accountId, id = id, + order = order, accounts = accounts.map { it.toEntity() }, unread = unread, lastStatus = lastStatus!!.toEntity() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt index c7224c4d2..7ff4daa74 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt @@ -19,22 +19,35 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.paging.LoadState import androidx.paging.LoadStateAdapter -import com.keylesspalace.tusky.adapter.NetworkStateViewHolder import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding +import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.visible class ConversationLoadStateAdapter( private val retryCallback: () -> Unit -) : LoadStateAdapter() { +) : LoadStateAdapter>() { - override fun onBindViewHolder(holder: NetworkStateViewHolder, loadState: LoadState) { - holder.setUpWithNetworkState(loadState) + override fun onBindViewHolder(holder: BindingHolder, loadState: LoadState) { + val binding = holder.binding + binding.progressBar.visible(loadState == LoadState.Loading) + binding.retryButton.visible(loadState is LoadState.Error) + val msg = if (loadState is LoadState.Error) { + loadState.error.message + } else { + null + } + binding.errorMsg.visible(msg != null) + binding.errorMsg.text = msg + binding.retryButton.setOnClickListener { + retryCallback() + } } override fun onCreateViewHolder( parent: ViewGroup, loadState: LoadState - ): NetworkStateViewHolder { + ): BindingHolder { val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return NetworkStateViewHolder(binding, retryCallback) + return BindingHolder(binding) } } 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 index 470675d17..fae55f0ba 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt @@ -20,6 +20,7 @@ import com.keylesspalace.tusky.viewdata.StatusViewData data class ConversationViewData( val id: String, + val order: Int, val accounts: List, val unread: Boolean, val lastStatus: StatusViewData.Concrete @@ -37,6 +38,7 @@ data class ConversationViewData( return ConversationEntity( accountId = accountId, id = id, + order = order, accounts = accounts, unread = unread, lastStatus = lastStatus.toConversationStatusEntity( @@ -71,6 +73,7 @@ fun StatusViewData.Concrete.toConversationStatusEntity( createdAt = status.createdAt, emojis = status.emojis, favouritesCount = status.favouritesCount, + repliesCount = status.repliesCount, favourited = favourited, bookmarked = bookmarked, sensitive = status.sensitive, 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 55c85fcdc..64801b6d5 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 @@ -23,6 +23,8 @@ import android.widget.Button; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; @@ -43,12 +45,12 @@ public class ConversationViewHolder extends StatusBaseViewHolder { private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; - private TextView conversationNameTextView; - private Button contentCollapseButton; - private ImageView[] avatars; + private final TextView conversationNameTextView; + private final Button contentCollapseButton; + private final ImageView[] avatars; - private StatusDisplayOptions statusDisplayOptions; - private StatusActionListener listener; + private final StatusDisplayOptions statusDisplayOptions; + private final StatusActionListener listener; ConversationViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions, @@ -64,7 +66,6 @@ public class ConversationViewHolder extends StatusBaseViewHolder { this.statusDisplayOptions = statusDisplayOptions; this.listener = listener; - } @Override @@ -72,52 +73,67 @@ public class ConversationViewHolder extends StatusBaseViewHolder { return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height); } - void setupWithConversation(ConversationViewData conversation) { + void setupWithConversation( + @NonNull ConversationViewData conversation, + @Nullable Object payloads + ) { + StatusViewData.Concrete statusViewData = conversation.getLastStatus(); Status status = statusViewData.getStatus(); - TimelineAccount account = status.getAccount(); - setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener); + if (payloads == null) { + TimelineAccount account = status.getAccount(); - setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions); - setUsername(account.getUsername()); - setCreatedAt(status.getCreatedAt(), statusDisplayOptions); - setIsReply(status.getInReplyToId() != null); - setFavourited(status.getFavourited()); - setBookmarked(status.getBookmarked()); - List attachments = status.getAttachments(); - boolean sensitive = status.getSensitive(); - if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { - setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(), - statusDisplayOptions.useBlurhash()); + setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener); - if (attachments.size() == 0) { + setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions); + setUsername(account.getUsername()); + setCreatedAt(status.getCreatedAt(), statusDisplayOptions); + setIsReply(status.getInReplyToId() != null); + setFavourited(status.getFavourited()); + setBookmarked(status.getBookmarked()); + List attachments = status.getAttachments(); + boolean sensitive = status.getSensitive(); + if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { + setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(), + statusDisplayOptions.useBlurhash()); + + if (attachments.size() == 0) { + hideSensitiveMediaWarning(); + } + // Hide the unused label. + for (TextView mediaLabel : mediaLabels) { + mediaLabel.setVisibility(View.GONE); + } + } else { + setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent()); + // Hide all unused views. + mediaPreviews[0].setVisibility(View.GONE); + mediaPreviews[1].setVisibility(View.GONE); + mediaPreviews[2].setVisibility(View.GONE); + mediaPreviews[3].setVisibility(View.GONE); hideSensitiveMediaWarning(); } - // Hide the unused label. - for (TextView mediaLabel : mediaLabels) { - mediaLabel.setVisibility(View.GONE); - } + + setupButtons(listener, account.getId(), statusViewData.getContent().toString(), + false, statusDisplayOptions); + + setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(), + status.getMentions(), status.getTags(), status.getEmojis(), + PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener); + + setConversationName(conversation.getAccounts()); + + setAvatars(conversation.getAccounts()); } else { - setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent()); - // Hide all unused views. - mediaPreviews[0].setVisibility(View.GONE); - mediaPreviews[1].setVisibility(View.GONE); - mediaPreviews[2].setVisibility(View.GONE); - mediaPreviews[3].setVisibility(View.GONE); - hideSensitiveMediaWarning(); + if (payloads instanceof List) { + for (Object item : (List) payloads) { + if (Key.KEY_CREATED.equals(item)) { + setCreatedAt(status.getCreatedAt(), statusDisplayOptions); + } + } + } } - - setupButtons(listener, account.getId(), statusViewData.getContent().toString(), - false, statusDisplayOptions); - - setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(), - status.getMentions(), status.getTags(), status.getEmojis(), - PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener); - - setConversationName(conversation.getAccounts()); - - setAvatars(conversation.getAccounts()); } private void setConversationName(List accounts) { 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 647bab428..dbe5b9238 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 @@ -22,21 +22,28 @@ import android.view.ViewGroup import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadState import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator +import at.connyduck.sparkbutton.helpers.Utils +import autodispose2.androidx.lifecycle.autoDispose import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.fragment.SFragment +import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.settings.PrefKeys @@ -45,29 +52,31 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding -import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.viewdata.AttachmentViewData +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import java.io.IOException import javax.inject.Inject +import kotlin.time.DurationUnit +import kotlin.time.toDuration -@OptIn(ExperimentalPagingApi::class) class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment { @Inject lateinit var viewModelFactory: ViewModelFactory + @Inject + lateinit var eventHub: EventHub + private val viewModel: ConversationsViewModel by viewModels { viewModelFactory } private val binding by viewBinding(FragmentTimelineBinding::bind) private lateinit var adapter: ConversationAdapter - private lateinit var loadStateAdapter: ConversationLoadStateAdapter - private var layoutManager: LinearLayoutManager? = null - - private var initialRefreshDone: Boolean = false + private var hideFab = false override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return inflater.inflate(R.layout.fragment_timeline, container, false) @@ -91,56 +100,106 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res ) adapter = ConversationAdapter(statusDisplayOptions, this) - loadStateAdapter = ConversationLoadStateAdapter(adapter::retry) - binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) - layoutManager = LinearLayoutManager(view.context) - binding.recyclerView.layoutManager = layoutManager - binding.recyclerView.adapter = adapter.withLoadStateFooter(loadStateAdapter) - (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false - - binding.progressBar.hide() - binding.statusView.hide() + setupRecyclerView() initSwipeToRefresh() + adapter.addLoadStateListener { loadState -> + if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { + binding.swipeRefreshLayout.isRefreshing = false + } + + binding.statusView.hide() + binding.progressBar.hide() + + if (adapter.itemCount == 0) { + when (loadState.refresh) { + is LoadState.NotLoading -> { + if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) { + binding.statusView.show() + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) + } + } + is LoadState.Error -> { + binding.statusView.show() + + if ((loadState.refresh as LoadState.Error).error is IOException) { + binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null) + } else { + binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null) + } + } + is LoadState.Loading -> { + binding.progressBar.show() + } + } + } + } + + adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + if (positionStart == 0 && adapter.itemCount != itemCount) { + binding.recyclerView.post { + if (getView() != null) { + binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) + } + } + } + } + }) + + hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false) + binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { + val composeButton = (activity as ActionButtonActivity).actionButton + if (composeButton != null) { + if (hideFab) { + if (dy > 0 && composeButton.isShown) { + composeButton.hide() // hides the button if we're scrolling down + } else if (dy < 0 && !composeButton.isShown) { + composeButton.show() // shows it if we are scrolling up + } + } else if (!composeButton.isShown) { + composeButton.show() + } + } + } + }) + viewLifecycleOwner.lifecycleScope.launch { viewModel.conversationFlow.collectLatest { pagingData -> adapter.submitData(pagingData) } } - adapter.addLoadStateListener { loadStates -> - - loadStates.refresh.let { refreshState -> - if (refreshState is LoadState.Error) { - binding.statusView.show() - if (refreshState.error is IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { - adapter.refresh() - } - } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { - adapter.refresh() - } - } - } else { - binding.statusView.hide() - } - - binding.progressBar.visible(refreshState == LoadState.Loading && adapter.itemCount == 0) - - if (refreshState is LoadState.NotLoading && !initialRefreshDone) { - // jump to top after the initial refresh finished - binding.recyclerView.scrollToPosition(0) - initialRefreshDone = true - } - - if (refreshState != LoadState.Loading) { - binding.swipeRefreshLayout.isRefreshing = false - } + lifecycleScope.launchWhenResumed { + val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) + while (!useAbsoluteTime) { + adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED)) + delay(1.toDuration(DurationUnit.MINUTES)) } } + + eventHub.events + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe { event -> + if (event is PreferenceChangedEvent) { + onPreferenceChanged(event.preferenceKey) + } + } + } + + private fun setupRecyclerView() { + binding.recyclerView.setHasFixedSize(true) + binding.recyclerView.layoutManager = LinearLayoutManager(context) + + binding.recyclerView.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + + (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + + binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry)) } private fun initSwipeToRefresh() { @@ -207,7 +266,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onOpenReblog(position: Int) { - // there are no reblogs in search results + // there are no reblogs in conversations } override fun onExpandedChange(expanded: Boolean, position: Int) { @@ -252,6 +311,19 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } } + override fun onVoteInPoll(position: Int, choices: MutableList) { + adapter.peek(position)?.let { conversation -> + viewModel.voteInPoll(choices, conversation) + } + } + + override fun onReselect() { + if (isAdded) { + binding.recyclerView.layoutManager?.scrollToPosition(0) + binding.recyclerView.stopScroll() + } + } + private fun deleteConversation(conversation: ConversationViewData) { AlertDialog.Builder(requireContext()) .setMessage(R.string.dialog_delete_conversation_warning) @@ -262,20 +334,20 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res .show() } - private fun jumpToTop() { - if (isAdded) { - layoutManager?.scrollToPosition(0) - binding.recyclerView.stopScroll() - } - } - - override fun onReselect() { - jumpToTop() - } - - override fun onVoteInPoll(position: Int, choices: MutableList) { - adapter.peek(position)?.let { conversation -> - viewModel.voteInPoll(choices, conversation) + private fun onPreferenceChanged(key: String) { + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + when (key) { + PrefKeys.FAB_HIDE -> { + hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) + } + PrefKeys.MEDIA_PREVIEW_ENABLED -> { + val enabled = accountManager.activeAccount!!.mediaPreviewEnabled + val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled + if (enabled != oldMediaPreviewEnabled) { + adapter.mediaPreviewEnabled = enabled + adapter.notifyItemRangeChanged(0, adapter.itemCount) + } + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt index 26984c8e8..02a44f951 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt @@ -4,8 +4,11 @@ import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator +import androidx.room.withTransaction import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.HttpHeaderLink +import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) class ConversationsRemoteMediator( @@ -14,38 +17,53 @@ class ConversationsRemoteMediator( private val db: AppDatabase ) : RemoteMediator() { + private var nextKey: String? = null + + private var order: Int = 0 + override suspend fun load( loadType: LoadType, state: PagingState ): MediatorResult { + if (loadType == LoadType.PREPEND) { + return MediatorResult.Success(endOfPaginationReached = true) + } + + if (loadType == LoadType.REFRESH) { + nextKey = null + order = 0 + } + try { - val conversationsResult = when (loadType) { - LoadType.REFRESH -> { - api.getConversations(limit = state.config.initialLoadSize) - } - LoadType.PREPEND -> { - return MediatorResult.Success(endOfPaginationReached = true) - } - LoadType.APPEND -> { - val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.lastStatus?.id - api.getConversations(maxId = maxId, limit = state.config.pageSize) - } + val conversationsResponse = api.getConversations(maxId = nextKey, limit = state.config.pageSize) + + val conversations = conversationsResponse.body() + if (!conversationsResponse.isSuccessful || conversations == null) { + return MediatorResult.Error(HttpException(conversationsResponse)) } - if (loadType == LoadType.REFRESH) { - db.conversationDao().deleteForAccount(accountId) + db.withTransaction { + + if (loadType == LoadType.REFRESH) { + db.conversationDao().deleteForAccount(accountId) + } + + val linkHeader = conversationsResponse.headers()["Link"] + val links = HttpHeaderLink.parse(linkHeader) + nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") + + db.conversationDao().insert( + conversations + .filterNot { it.lastStatus == null } + .map { + it.toEntity(accountId, order++) + } + ) } - db.conversationDao().insert( - conversationsResult - .filterNot { it.lastStatus == null } - .map { it.toEntity(accountId) } - ) - return MediatorResult.Success(endOfPaginationReached = conversationsResult.isEmpty()) + return MediatorResult.Success(endOfPaginationReached = nextKey == null) } catch (e: Exception) { return MediatorResult.Error(e) } } - - override suspend fun initialize() = InitializeAction.LAUNCH_INITIAL_REFRESH } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt deleted file mode 100644 index 12c5eb0bb..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* Copyright 2021 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.db.AppDatabase -import com.keylesspalace.tusky.network.MastodonApi -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.schedulers.Schedulers -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class ConversationsRepository @Inject constructor( - val mastodonApi: MastodonApi, - val db: AppDatabase -) { - - fun deleteCacheForAccount(accountId: Long) { - Single.fromCallable { - db.conversationDao().deleteForAccount(accountId) - }.subscribeOn(Schedulers.io()) - .subscribe() - } -} 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 9326a05c0..735aa26c1 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 @@ -26,7 +26,7 @@ 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.usecase.TimelineCases import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.await @@ -41,7 +41,7 @@ class ConversationsViewModel @Inject constructor( @OptIn(ExperimentalPagingApi::class) val conversationFlow = Pager( - config = PagingConfig(pageSize = 10, enablePlaceholders = false, initialLoadSize = 20), + config = PagingConfig(pageSize = 30), remoteMediator = ConversationsRemoteMediator(accountManager.activeAccount!!.id, api, database), pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) } ) 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 index a238f510a..89b19a5b1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt @@ -16,6 +16,9 @@ package com.keylesspalace.tusky.components.instanceinfo import android.util.Log +import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.getOrElse +import at.connyduck.calladapter.networkresult.onSuccess import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.EmojisEntity 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 8ea2ce6cb..b81f2374a 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 @@ -26,6 +26,7 @@ import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope +import at.connyduck.calladapter.networkresult.fold import com.bumptech.glide.Glide import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BuildConfig @@ -33,6 +34,7 @@ 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.AccessToken import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.viewBinding @@ -228,26 +230,50 @@ class LoginActivity : BaseActivity(), Injectable { domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code" ).fold( { accessToken -> - accountManager.addAccount(accessToken.accessToken, domain, OAUTH_SCOPES) - - val intent = Intent(this, MainActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - startActivity(intent) - finish() - overridePendingTransition(R.anim.explode, R.anim.explode) + fetchAccountDetails(accessToken, domain, clientId, clientSecret) }, { 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), - ) + Log.e(TAG, getString(R.string.error_retrieving_oauth_token), e) } ) } + private suspend fun fetchAccountDetails( + accessToken: AccessToken, + domain: String, + clientId: String, + clientSecret: String + ) { + + mastodonApi.accountVerifyCredentials( + domain = domain, + auth = "Bearer ${accessToken.accessToken}" + ).fold({ newAccount -> + accountManager.addAccount( + accessToken = accessToken.accessToken, + domain = domain, + clientId = clientId, + clientSecret = clientSecret, + oauthScopes = OAUTH_SCOPES, + newAccount = newAccount + ) + + 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_loading_account_details) + Log.e(TAG, getString(R.string.error_loading_account_details), e) + }) + } + private fun setLoading(loadingState: Boolean) { if (loadingState) { binding.loginLoadingLayout.visibility = View.VISIBLE 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 2ed38720b..07bd56529 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 @@ -83,6 +83,10 @@ class LoginWebViewActivity : BaseActivity(), Injectable { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (BuildConfig.DEBUG) { + WebView.setWebContentsDebuggingEnabled(true) + } + val data = OauthLogin.parseData(intent) setContentView(binding.root) 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 ce11ab1cc..45ecd0f65 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 @@ -458,24 +458,6 @@ public class NotificationHelper { } } - public static void deleteLegacyNotificationChannels(@NonNull Context context, @NonNull AccountManager accountManager) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - - // used until Tusky 1.4 - notificationManager.deleteNotificationChannel(CHANNEL_MENTION); - notificationManager.deleteNotificationChannel(CHANNEL_FAVOURITE); - notificationManager.deleteNotificationChannel(CHANNEL_BOOST); - notificationManager.deleteNotificationChannel(CHANNEL_FOLLOW); - - // used until Tusky 1.7 - for(AccountEntity account: accountManager.getAllAccountsOrderedByActive()) { - notificationManager.deleteNotificationChannel(CHANNEL_FAVOURITE+" "+account.getIdentifier()); - } - } - } - public static boolean areNotificationsEnabled(@NonNull Context context, @NonNull AccountManager accountManager) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt index ec2c82ac9..0d804dd97 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt @@ -23,6 +23,8 @@ import android.util.Log import android.view.View import androidx.appcompat.app.AlertDialog import androidx.preference.PreferenceManager +import at.connyduck.calladapter.networkresult.onFailure +import at.connyduck.calladapter.networkresult.onSuccess import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.login.LoginActivity @@ -49,7 +51,12 @@ private fun accountNeedsMigration(account: AccountEntity): Boolean = fun currentAccountNeedsMigration(accountManager: AccountManager): Boolean = accountManager.activeAccount?.let(::accountNeedsMigration) ?: false -fun showMigrationNoticeIfNecessary(context: Context, parent: View, accountManager: AccountManager) { +fun showMigrationNoticeIfNecessary( + context: Context, + parent: View, + anchorView: View?, + accountManager: AccountManager +) { // No point showing anything if we cannot enable it if (!isUnifiedPushAvailable(context)) return if (!anyAccountNeedsMigration(accountManager)) return @@ -57,10 +64,10 @@ fun showMigrationNoticeIfNecessary(context: Context, parent: View, accountManage val pm = PreferenceManager.getDefaultSharedPreferences(context) if (pm.getBoolean(KEY_MIGRATION_NOTICE_DISMISSED, false)) return - Snackbar.make(parent, R.string.tips_push_notification_migration, Snackbar.LENGTH_INDEFINITE).apply { - setAction(R.string.action_details) { showMigrationExplanationDialog(context, accountManager) } - show() - } + Snackbar.make(parent, R.string.tips_push_notification_migration, Snackbar.LENGTH_INDEFINITE) + .setAnchorView(anchorView) + .setAction(R.string.action_details) { showMigrationExplanationDialog(context, accountManager) } + .show() } private fun showMigrationExplanationDialog(context: Context, accountManager: AccountManager) { @@ -87,7 +94,7 @@ private suspend fun enableUnifiedPushNotificationsForAccount(context: Context, a // Already registered, update the subscription to match notification settings updateUnifiedPushSubscription(context, api, accountManager, account) } else { - UnifiedPush.registerAppWithDialog(context, account.id.toString()) + UnifiedPush.registerAppWithDialog(context, account.id.toString(), features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE)) } } @@ -150,7 +157,14 @@ private fun buildSubscriptionData(context: Context, account: AccountEntity): Map } // Called by UnifiedPush callback -suspend fun registerUnifiedPushEndpoint(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity, endpoint: String) { +suspend fun registerUnifiedPushEndpoint( + context: Context, + api: MastodonApi, + accountManager: AccountManager, + account: AccountEntity, + endpoint: String +) = withContext(Dispatchers.IO) { + // Generate a prime256v1 key pair for WebPush // Decryption is unimplemented for now, since Mastodon uses an old WebPush // standard which does not send needed information for decryption in the payload @@ -159,27 +173,22 @@ suspend fun registerUnifiedPushEndpoint(context: Context, api: MastodonApi, acco val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1) val auth = CryptoUtil.secureRandomBytesEncoded(16) - withContext(Dispatchers.IO) { - api.subscribePushNotifications( - "Bearer ${account.accessToken}", account.domain, - endpoint, keyPair.pubkey, auth, - buildSubscriptionData(context, account) - ).onFailure { - Log.d(TAG, "Error setting push endpoint for account ${account.id}") - Log.d(TAG, Log.getStackTraceString(it)) - Log.d(TAG, (it as HttpException).response().toString()) + api.subscribePushNotifications( + "Bearer ${account.accessToken}", account.domain, + endpoint, keyPair.pubkey, auth, + buildSubscriptionData(context, account) + ).onFailure { throwable -> + Log.w(TAG, "Error setting push endpoint for account ${account.id}", throwable) + disableUnifiedPushNotificationsForAccount(context, account) + }.onSuccess { + Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}") - disableUnifiedPushNotificationsForAccount(context, account) - }.onSuccess { - Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}") - - account.pushPubKey = keyPair.pubkey - account.pushPrivKey = keyPair.privKey - account.pushAuth = auth - account.pushServerKey = it.serverKey - account.unifiedPushUrl = endpoint - accountManager.saveAccount(account) - } + account.pushPubKey = keyPair.pubkey + account.pushPrivKey = keyPair.privKey + account.pushAuth = auth + account.pushServerKey = it.serverKey + account.unifiedPushUrl = endpoint + accountManager.saveAccount(account) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt index 71c5e10ec..c0a763294 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt @@ -39,7 +39,7 @@ class TabFilterPreferencesFragment : PreferenceFragmentCompat() { checkBoxPreference { setTitle(R.string.pref_title_show_replies) key = PrefKeys.TAB_FILTER_HOME_REPLIES - setDefaultValue(false) + setDefaultValue(true) isIconSpaceReserved = false } } 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 766ed44ab..483c8e4df 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 @@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn +import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.network.MastodonApi diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt index 5e321c08c..16035b044 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt @@ -30,7 +30,7 @@ import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.NotestockApi -import com.keylesspalace.tusky.network.TimelineCases +import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData 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 c5ad77344..af2660761 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 @@ -107,9 +107,6 @@ class TimelineFragment : private lateinit var adapter: TimelinePagingAdapter private var isSwipeToRefreshEnabled = true - - private var layoutManager: LinearLayoutManager? = null - private var scrollListener: RecyclerView.OnScrollListener? = null private var hideFab = false override fun onCreate(savedInstanceState: Bundle?) { @@ -244,7 +241,7 @@ class TimelineFragment : if (actionButtonPresent()) { val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) hideFab = preferences.getBoolean("fabHide", false) - scrollListener = object : RecyclerView.OnScrollListener() { + binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { val composeButton = (activity as ActionButtonActivity).actionButton if (composeButton != null) { @@ -259,9 +256,7 @@ class TimelineFragment : } } } - }.also { - binding.recyclerView.addOnScrollListener(it) - } + }) } eventHub.events @@ -297,8 +292,7 @@ class TimelineFragment : } ) binding.recyclerView.setHasFixedSize(true) - layoutManager = LinearLayoutManager(context) - binding.recyclerView.layoutManager = layoutManager + binding.recyclerView.layoutManager = LinearLayoutManager(context) val divider = DividerItemDecoration(context, RecyclerView.VERTICAL) binding.recyclerView.addItemDecoration(divider) @@ -509,7 +503,7 @@ class TimelineFragment : val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) if (!useAbsoluteTime) { - Observable.interval(1, TimeUnit.MINUTES) + Observable.interval(0, 1, TimeUnit.MINUTES) .observeOn(AndroidSchedulers.mainThread()) .autoDispose(this, Lifecycle.Event.ON_PAUSE) .subscribe { @@ -520,7 +514,7 @@ class TimelineFragment : override fun onReselect() { if (isAdded) { - layoutManager!!.scrollToPosition(0) + binding.recyclerView.layoutManager?.scrollToPosition(0) binding.recyclerView.stopScroll() } } 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 12a0774d4..2a345fdf5 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 @@ -99,6 +99,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { contentShowing = false, pinned = false, card = null, + repliesCount = 0, quote = null, ) } @@ -141,6 +142,7 @@ fun Status.toEntity( contentCollapsed = contentCollapsed, pinned = actionableStatus.pinned == true, card = actionableStatus.card?.let(gson::toJson), + repliesCount = actionableStatus.repliesCount, quote = actionableStatus.quote?.let(gson::toJson), ) } @@ -186,6 +188,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { muted = status.muted, poll = poll, card = card, + repliesCount = status.repliesCount, quote = quote, ) } @@ -216,6 +219,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { muted = status.muted, poll = null, card = null, + repliesCount = status.repliesCount, quote = null, ) } else { @@ -245,6 +249,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { muted = status.muted, poll = poll, card = card, + repliesCount = status.repliesCount, quote = quote, ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index c4aa2c72d..ebab4440a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -51,6 +51,10 @@ class CachedTimelineRemoteMediator( state: PagingState ): MediatorResult { + if (!activeAccount.isLoggedIn()) { + return MediatorResult.Success(endOfPaginationReached = true) + } + try { var dbEmpty = false 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 b945debeb..ba8bc5ae1 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 @@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig +import androidx.paging.PagingSource import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map @@ -37,11 +38,12 @@ import com.keylesspalace.tusky.components.timeline.toViewData import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.network.TimelineCases +import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor @@ -69,7 +71,17 @@ class CachedTimelineViewModel @Inject constructor( private val db: AppDatabase, private val gson: Gson, streamingManager: StreamingManager, -) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel, streamingManager) { +) : TimelineViewModel( + timelineCases, + api, + eventHub, + accountManager, + sharedPreferences, + filterModel, + streamingManager, +) { + + private var currentPagingSource: PagingSource? = null @OptIn(ExperimentalPagingApi::class) override val statuses = Pager( @@ -81,6 +93,8 @@ class CachedTimelineViewModel @Inject constructor( EmptyTimelinePagingSource() } else { db.timelineDao().getStatuses(activeAccount.id) + }.also { newPagingSource -> + this.currentPagingSource = newPagingSource } } ).flow @@ -116,13 +130,15 @@ class CachedTimelineViewModel @Inject constructor( override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { - db.timelineDao().setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing) + db.timelineDao() + .setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing) } } override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { - db.timelineDao().setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed) + db.timelineDao() + .setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed) } } @@ -149,12 +165,21 @@ class CachedTimelineViewModel @Inject constructor( val activeAccount = accountManager.activeAccount!! - timelineDao.insertStatus(Placeholder(placeholderId, loading = true).toEntity(activeAccount.id)) + timelineDao.insertStatus( + Placeholder(placeholderId, loading = true).toEntity( + activeAccount.id + ) + ) val response = db.withTransaction { val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId) - val nextPlaceholderId = timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId) - api.homeTimeline(maxId = idAbovePlaceholder, sinceId = nextPlaceholderId, limit = LOAD_AT_ONCE) + val nextPlaceholderId = + timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId) + api.homeTimeline( + maxId = idAbovePlaceholder, + sinceId = nextPlaceholderId, + limit = LOAD_AT_ONCE + ) }.await() val statuses = response.body() @@ -168,16 +193,21 @@ class CachedTimelineViewModel @Inject constructor( timelineDao.delete(activeAccount.id, placeholderId) val overlappedStatuses = if (statuses.isNotEmpty()) { - timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id) + timelineDao.deleteRange( + activeAccount.id, + statuses.last().id, + statuses.first().id + ) } else { 0 } for (status in statuses) { timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson)) - status.reblog?.account?.toEntity(activeAccount.id, gson)?.let { rebloggedAccount -> - timelineDao.insertAccount(rebloggedAccount) - } + status.reblog?.account?.toEntity(activeAccount.id, gson) + ?.let { rebloggedAccount -> + timelineDao.insertAccount(rebloggedAccount) + } timelineDao.insertStatus( status.toEntity( timelineUserId = activeAccount.id, @@ -196,7 +226,10 @@ class CachedTimelineViewModel @Inject constructor( to guarantee the placeholder has an id that exists on the server as not all servers handle client generated ids as expected */ timelineDao.insertStatus( - Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id) + Placeholder( + statuses.last().id, + loading = false + ).toEntity(activeAccount.id) ) } } @@ -211,7 +244,8 @@ class CachedTimelineViewModel @Inject constructor( private suspend fun loadMoreFailed(placeholderId: String, e: Exception) { Log.w("CachedTimelineVM", "failed loading statuses", e) val activeAccount = accountManager.activeAccount!! - db.timelineDao().insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id)) + db.timelineDao() + .insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id)) } override fun handleReblogEvent(reblogEvent: ReblogEvent) { @@ -266,6 +300,13 @@ class CachedTimelineViewModel @Inject constructor( } } + override suspend fun invalidate() { + // invalidating when we don't have statuses yet can cause empty timelines because it cancels the network load + if (db.timelineDao().getStatusCount(accountManager.activeAccount!!.id) > 0) { + currentPagingSource?.invalidate() + } + } + companion object { private const val MAX_STATUSES_IN_CACHE = 1000 } 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 0685a92eb..a8dd4f718 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 @@ -34,7 +34,7 @@ import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.network.TimelineCases +import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.getDomain import com.keylesspalace.tusky.util.isLessThan import com.keylesspalace.tusky.util.isLessThanOrEqual @@ -270,6 +270,10 @@ class NetworkTimelineViewModel @Inject constructor( currentSource?.invalidate() } + override suspend fun invalidate() { + currentSource?.invalidate() + } + @Throws(IOException::class, HttpException::class) suspend fun fetchStatusesForKind( fromId: String?, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index fddfc6e37..2364ba3ea 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -41,8 +41,8 @@ import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -140,6 +140,7 @@ abstract class TimelineViewModel( this.isStreamingEnabled = isStreamingEnabled if (kind == Kind.HOME) { + // Note the variable is "true if filter" but the underlying preference/settings text is "true if show" filterRemoveReplies = !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) filterRemoveReblogs = @@ -233,6 +234,9 @@ abstract class TimelineViewModel( abstract fun fullReload() + /** Triggered when currently displayed data must be reloaded. */ + protected abstract suspend fun invalidate() + protected fun shouldFilterStatus(statusViewData: StatusViewData): Boolean { val status = statusViewData.asStatusOrNull()?.status ?: return false return status.inReplyToId != null && filterRemoveReplies || @@ -353,6 +357,9 @@ abstract class TimelineViewModel( filterContextMatchesKind(kind, it.context) } ) + // After the filters are loaded we need to reload displayed content to apply them. + // It can happen during the usage or at startup, when we get statuses before filters. + invalidate() } } 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 0b717120c..5ffc9021d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -37,6 +37,8 @@ data class AccountEntity( @field:PrimaryKey(autoGenerate = true) var id: Long, val domain: String, var accessToken: String, + var clientId: String?, // nullable for backward compatibility + var clientSecret: String?, // nullable for backward compatibility var isActive: Boolean, var accountId: String = "", var username: String = "", @@ -81,6 +83,15 @@ data class AccountEntity( val fullName: String get() = "@$username@$domain" + fun logout() { + // deleting credentials so they cannot be used again + accessToken = "" + clientId = null + clientSecret = null + } + + fun isLoggedIn() = accessToken.isNotEmpty() + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index 2ddbe5223..9c5e118b3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -48,13 +48,22 @@ class AccountManager @Inject constructor(db: AppDatabase) { } /** - * Adds a new empty account and makes it the active account. - * More account information has to be added later with [updateActiveAccount] - * or the account wont be saved to the database. + * Adds a new account and makes it the active account. * @param accessToken the access token for the new account * @param domain the domain of the accounts Mastodon instance + * @param clientId the oauth client id used to sign in the account + * @param clientSecret the oauth client secret used to sign in the account + * @param oauthScopes the oauth scopes granted to the account + * @param newAccount the [Account] as returned by the Mastodon Api */ - fun addAccount(accessToken: String, domain: String, oauthScopes: String) { + fun addAccount( + accessToken: String, + domain: String, + clientId: String, + clientSecret: String, + oauthScopes: String, + newAccount: Account + ) { activeAccount?.let { it.isActive = false @@ -62,13 +71,35 @@ class AccountManager @Inject constructor(db: AppDatabase) { accountDao.insertOrReplace(it) } + // check if this is a relogin with an existing account, if yes update it, otherwise create a new one + val existingAccountIndex = accounts.indexOfFirst { account -> + domain == account.domain && newAccount.id == account.accountId + } + val newAccountEntity = if (existingAccountIndex != -1) { + accounts[existingAccountIndex].copy( + accessToken = accessToken, + clientId = clientId, + clientSecret = clientSecret, + oauthScopes = oauthScopes, + isActive = true + ).also { accounts[existingAccountIndex] = it } + } else { + val maxAccountId = accounts.maxByOrNull { it.id }?.id ?: 0 + val newAccountId = maxAccountId + 1 + AccountEntity( + id = newAccountId, + domain = domain.lowercase(Locale.ROOT), + accessToken = accessToken, + clientId = clientId, + clientSecret = clientSecret, + oauthScopes = oauthScopes, + isActive = true, + accountId = newAccount.id + ).also { accounts.add(it) } + } - val maxAccountId = accounts.maxByOrNull { it.id }?.id ?: 0 - val newAccountId = maxAccountId + 1 - activeAccount = AccountEntity( - id = newAccountId, domain = domain.lowercase(Locale.ROOT), - accessToken = accessToken, oauthScopes = oauthScopes, isActive = true - ) + activeAccount = newAccountEntity + updateActiveAccount(newAccount) } /** @@ -89,11 +120,12 @@ class AccountManager @Inject constructor(db: AppDatabase) { */ fun logActiveAccountOut(): AccountEntity? { - if (activeAccount == null) { - return null - } else { - accounts.remove(activeAccount!!) - accountDao.delete(activeAccount!!) + return activeAccount?.let { account -> + + account.logout() + + accounts.remove(account) + accountDao.delete(account) if (accounts.size > 0) { accounts[0].isActive = true @@ -103,7 +135,7 @@ class AccountManager @Inject constructor(db: AppDatabase) { } else { activeAccount = null } - return activeAccount + activeAccount } } @@ -123,17 +155,7 @@ class AccountManager @Inject constructor(db: AppDatabase) { it.emojis = account.emojis ?: emptyList() Log.d(TAG, "updateActiveAccount: saving account with id " + it.id) - it.id = accountDao.insertOrReplace(it) - - val accountIndex = accounts.indexOf(it) - - if (accountIndex != -1) { - // in case the user was already logged in with this account, remove the old information - accounts.removeAt(accountIndex) - accounts.add(accountIndex, it) - } else { - accounts.add(it) - } + accountDao.insertOrReplace(it) } } 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 ced64d11a..d945b0c94 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 = 36) + }, version = 39) public abstract class AppDatabase extends RoomDatabase { public abstract AccountDao accountDao(); @@ -554,4 +554,32 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushServerKey` TEXT NOT NULL DEFAULT ''"); } }; + + public static final Migration MIGRATION_36_37 = new Migration(36, 37) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `repliesCount` INTEGER NOT NULL DEFAULT 0"); + database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_repliesCount` INTEGER NOT NULL DEFAULT 0"); + } + }; + + public static final Migration MIGRATION_37_38 = new Migration(37, 38) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + // database needs to be cleaned because the ConversationAccountEntity got a new attribute + database.execSQL("DELETE FROM `ConversationEntity`"); + database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `order` INTEGER NOT NULL DEFAULT 0"); + + // timestamps are now serialized differently so all cache tables that contain them need to be cleaned + database.execSQL("DELETE FROM `TimelineStatusEntity`"); + } + }; + + public static final Migration MIGRATION_38_39 = new Migration(38, 39) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `clientId` TEXT"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `clientSecret` 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 fe093bd0c..001dbbe5e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt @@ -28,14 +28,14 @@ interface ConversationsDao { suspend fun insert(conversations: List) @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(conversation: ConversationEntity): Long + suspend fun insert(conversation: ConversationEntity) @Query("DELETE FROM ConversationEntity WHERE id = :id AND accountId = :accountId") - suspend fun delete(id: String, accountId: Long): Int + suspend fun delete(id: String, accountId: Long) - @Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC") + @Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY `order` ASC") fun conversationsForAccount(accountId: Long): PagingSource @Query("DELETE FROM ConversationEntity WHERE accountId = :accountId") - fun deleteForAccount(accountId: Long) + suspend fun deleteForAccount(accountId: Long) } 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 9b190bc7f..3687da09e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt @@ -19,6 +19,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.RewriteQueriesToDropUnusedColumns @Dao interface InstanceDao { @@ -29,9 +30,11 @@ interface InstanceDao { @Insert(onConflict = OnConflictStrategy.REPLACE, entity = InstanceEntity::class) suspend fun insertOrReplace(emojis: EmojisEntity) + @RewriteQueriesToDropUnusedColumns @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") suspend fun getInstanceInfo(instance: String): InstanceInfoEntity? + @RewriteQueriesToDropUnusedColumns @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/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index 8c4f5147c..6072abf8c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -34,7 +34,7 @@ abstract class TimelineDao { """ 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.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, 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.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.quote, @@ -198,4 +198,7 @@ AND timelineUserId = :accountId */ @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") abstract suspend fun getNextPlaceholderIdAfter(accountId: Long, serverId: String): String? + + @Query("SELECT COUNT(*) FROM TimelineStatusEntity WHERE timelineUserId = :accountId") + abstract suspend fun getStatusCount(accountId: Long): Int } 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 942a5ce04..05c3747cf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -61,6 +61,7 @@ data class TimelineStatusEntity( val emojis: String?, val reblogsCount: Int, val favouritesCount: Int, + val repliesCount: Int, val reblogged: Boolean, val bookmarked: Boolean, val favourited: Boolean, 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 ab063d7e8..947a83236 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -70,7 +70,8 @@ class AppModule { AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29, AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32, AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34, AppDatabase.MIGRATION_34_35, - AppDatabase.MIGRATION_35_36, + AppDatabase.MIGRATION_35_36, AppDatabase.MIGRATION_36_37, AppDatabase.MIGRATION_37_38, + AppDatabase.MIGRATION_38_39 ) .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 0a79044ae..44a9a799f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -18,10 +18,12 @@ package com.keylesspalace.tusky.di import android.content.Context import android.content.SharedPreferences import android.os.Build -import at.connyduck.calladapter.kotlinresult.KotlinResultCallAdapterFactory +import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory 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.Rfc3339DateJsonAdapter import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MediaUploadApi @@ -40,6 +42,7 @@ import retrofit2.converter.gson.GsonConverterFactory import retrofit2.create import java.net.InetSocketAddress import java.net.Proxy +import java.util.Date import java.util.concurrent.TimeUnit import javax.inject.Singleton @@ -52,7 +55,9 @@ class NetworkModule { @Provides @Singleton - fun providesGson() = Gson() + fun providesGson(): Gson = GsonBuilder() + .registerTypeAdapter(Date::class.java, Rfc3339DateJsonAdapter()) + .create() @Provides @Singleton @@ -109,7 +114,7 @@ class NetworkModule { .client(httpClient) .addConverterFactory(GsonConverterFactory.create(gson)) .addCallAdapterFactory(RxJava3CallAdapterFactory.create()) - .addCallAdapterFactory(KotlinResultCallAdapterFactory.create()) + .addCallAdapterFactory(NetworkResultCallAdapterFactory.create()) .build() } 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 971d53139..abdeea689 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -33,6 +33,7 @@ data class Status( val emojis: List, @SerializedName("reblogs_count") val reblogsCount: Int, @SerializedName("favourites_count") val favouritesCount: Int, + @SerializedName("replies_count") val repliesCount: Int, var reblogged: Boolean, var favourited: Boolean, var bookmarked: Boolean, 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 4f6978d2d..87df99aa0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -547,7 +547,7 @@ public class NotificationsFragment extends SFragment implements @Override public void onContentCollapsedChange(boolean isCollapsed, int position) { - updateViewDataAt(position, (vd) -> vd.copyWIthCollapsed(isCollapsed)); + updateViewDataAt(position, (vd) -> vd.copyWithCollapsed(isCollapsed)); ; } @@ -972,10 +972,10 @@ public class NotificationsFragment extends SFragment implements if (notifications.size() == 0 && adapter.getItemCount() == 0) { this.statusView.setVisibility(View.VISIBLE); this.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); - } else { - swipeRefreshLayout.setEnabled(true); } + updateFilterVisibility(); + swipeRefreshLayout.setEnabled(true); swipeRefreshLayout.setRefreshing(false); progressBar.setVisibility(View.GONE); } @@ -1240,7 +1240,7 @@ public class NotificationsFragment extends SFragment implements SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false); if (!useAbsoluteTime) { - Observable.interval(1, TimeUnit.MINUTES) + Observable.interval(0, 1, TimeUnit.MINUTES) .observeOn(AndroidSchedulers.mainThread()) .to(autoDisposable(from(this, Lifecycle.Event.ON_PAUSE))) .subscribe( 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 e77b6de20..dd4f02a33 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -56,7 +56,7 @@ import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.network.MastodonApi; -import com.keylesspalace.tusky.network.TimelineCases; +import com.keylesspalace.tusky.usecase.TimelineCases; import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.util.StatusParsingHelper; import com.keylesspalace.tusky.view.MuteAccountDialog; diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java index 78adc9ed8..87be7e03c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -397,7 +397,7 @@ public final class ViewThreadFragment extends SFragment implements public void onContentCollapsedChange(boolean isCollapsed, int position) { adapter.setItem( position, - statuses.getPairedItem(position).copyWIthCollapsed(isCollapsed), + statuses.getPairedItem(position).copyWithCollapsed(isCollapsed), true ); } diff --git a/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt b/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt new file mode 100644 index 000000000..bd8df6b5e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt @@ -0,0 +1,268 @@ +package com.keylesspalace.tusky.json + +/* + * Copyright (C) 2011 FasterXML, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import com.google.gson.JsonParseException +import java.util.Calendar +import java.util.Date +import java.util.GregorianCalendar +import java.util.Locale +import java.util.TimeZone +import kotlin.math.min +import kotlin.math.pow + +/* + * Jackson’s date formatter, pruned to Moshi's needs. Forked from this file: + * https://github.com/FasterXML/jackson-databind/blob/67ebf7305f492285a8f9f4de31545f5f16fc7c3a/src/main/java/com/fasterxml/jackson/databind/util/ISO8601Utils.java + * + * Utilities methods for manipulating dates in iso8601 format. This is much much faster and GC + * friendly than using SimpleDateFormat so highly suitable if you (un)serialize lots of date + * objects. + * + * Supported parse format: + * `[yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:]mm]]` + * + * @see [this specification](http://www.w3.org/TR/NOTE-datetime) + */ + +/** ID to represent the 'GMT' string */ +private const val GMT_ID = "GMT" + +/** The GMT timezone, prefetched to avoid more lookups. */ +private val TIMEZONE_Z: TimeZone = TimeZone.getTimeZone(GMT_ID) + +/** Returns `date` formatted as yyyy-MM-ddThh:mm:ss.sssZ */ +internal fun Date.formatIsoDate(): String { + val calendar: Calendar = GregorianCalendar(TIMEZONE_Z, Locale.US) + calendar.time = this + + // estimate capacity of buffer as close as we can (yeah, that's pedantic ;) + val capacity = "yyyy-MM-ddThh:mm:ss.sssZ".length + val formatted = StringBuilder(capacity) + padInt(formatted, calendar[Calendar.YEAR], "yyyy".length) + formatted.append('-') + padInt(formatted, calendar[Calendar.MONTH] + 1, "MM".length) + formatted.append('-') + padInt(formatted, calendar[Calendar.DAY_OF_MONTH], "dd".length) + formatted.append('T') + padInt(formatted, calendar[Calendar.HOUR_OF_DAY], "hh".length) + formatted.append(':') + padInt(formatted, calendar[Calendar.MINUTE], "mm".length) + formatted.append(':') + padInt(formatted, calendar[Calendar.SECOND], "ss".length) + formatted.append('.') + padInt(formatted, calendar[Calendar.MILLISECOND], "sss".length) + formatted.append('Z') + return formatted.toString() +} + +/** + * Parse a date from ISO-8601 formatted string. It expects a format + * `[yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh:mm]]` + * + * @receiver ISO string to parse in the appropriate format. + * @return the parsed date + */ +internal fun String.parseIsoDate(): Date { + return try { + var offset = 0 + + // extract year + val year = parseInt(this, offset, 4.let { offset += it; offset }) + if (checkOffset(this, offset, '-')) { + offset += 1 + } + + // extract month + val month = parseInt(this, offset, 2.let { offset += it; offset }) + if (checkOffset(this, offset, '-')) { + offset += 1 + } + + // extract day + val day = parseInt(this, offset, 2.let { offset += it; offset }) + // default time value + var hour = 0 + var minutes = 0 + var seconds = 0 + // always use 0 otherwise returned date will include millis of current time + var milliseconds = 0 + + // if the value has no time component (and no time zone), we are done + val hasT = checkOffset(this, offset, 'T') + if (!hasT && this.length <= offset) { + return GregorianCalendar(year, month - 1, day).time + } + if (hasT) { + + // extract hours, minutes, seconds and milliseconds + hour = parseInt(this, 1.let { offset += it; offset }, 2.let { offset += it; offset }) + if (checkOffset(this, offset, ':')) { + offset += 1 + } + minutes = parseInt(this, offset, 2.let { offset += it; offset }) + if (checkOffset(this, offset, ':')) { + offset += 1 + } + // second and milliseconds can be optional + if (this.length > offset) { + val c = this[offset] + if (c != 'Z' && c != '+' && c != '-') { + seconds = parseInt(this, offset, 2.let { offset += it; offset }) + if (seconds in 60..62) seconds = 59 // truncate up to 3 leap seconds + // milliseconds can be optional in the format + if (checkOffset(this, offset, '.')) { + offset += 1 + val endOffset = indexOfNonDigit(this, offset + 1) // assume at least one digit + val parseEndOffset = min(endOffset, offset + 3) // parse up to 3 digits + val fraction = parseInt(this, offset, parseEndOffset) + milliseconds = + (10.0.pow((3 - (parseEndOffset - offset)).toDouble()) * fraction).toInt() + offset = endOffset + } + } + } + } + + // extract timezone + require(this.length > offset) { "No time zone indicator" } + val timezone: TimeZone + val timezoneIndicator = this[offset] + if (timezoneIndicator == 'Z') { + timezone = TIMEZONE_Z + } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { + val timezoneOffset = this.substring(offset) + // 18-Jun-2015, tatu: Minor simplification, skip offset of "+0000"/"+00:00" + if ("+0000" == timezoneOffset || "+00:00" == timezoneOffset) { + timezone = TIMEZONE_Z + } else { + // 18-Jun-2015, tatu: Looks like offsets only work from GMT, not UTC... + // not sure why, but it is what it is. + val timezoneId = GMT_ID + timezoneOffset + timezone = TimeZone.getTimeZone(timezoneId) + val act = timezone.id + if (act != timezoneId) { + /* + * 22-Jan-2015, tatu: Looks like canonical version has colons, but we may be given + * one without. If so, don't sweat. + * Yes, very inefficient. Hopefully not hit often. + * If it becomes a perf problem, add 'loose' comparison instead. + */ + val cleaned = act.replace(":", "") + if (cleaned != timezoneId) { + throw IndexOutOfBoundsException( + "Mismatching time zone indicator: $timezoneId given, resolves to ${timezone.id}" + ) + } + } + } + } else { + throw IndexOutOfBoundsException( + "Invalid time zone indicator '$timezoneIndicator'" + ) + } + val calendar: Calendar = GregorianCalendar(timezone) + calendar.isLenient = false + calendar[Calendar.YEAR] = year + calendar[Calendar.MONTH] = month - 1 + calendar[Calendar.DAY_OF_MONTH] = day + calendar[Calendar.HOUR_OF_DAY] = hour + calendar[Calendar.MINUTE] = minutes + calendar[Calendar.SECOND] = seconds + calendar[Calendar.MILLISECOND] = milliseconds + calendar.time + // If we get a ParseException it'll already have the right message/offset. + // Other exception types can convert here. + } catch (e: IndexOutOfBoundsException) { + throw JsonParseException("Not an RFC 3339 date: $this", e) + } catch (e: IllegalArgumentException) { + throw JsonParseException("Not an RFC 3339 date: $this", e) + } +} + +/** + * Check if the expected character exist at the given offset in the value. + * + * @param value the string to check at the specified offset + * @param offset the offset to look for the expected character + * @param expected the expected character + * @return true if the expected character exist at the given offset + */ +private fun checkOffset(value: String, offset: Int, expected: Char): Boolean { + return offset < value.length && value[offset] == expected +} + +/** + * Parse an integer located between 2 given offsets in a string + * + * @param value the string to parse + * @param beginIndex the start index for the integer in the string + * @param endIndex the end index for the integer in the string + * @return the int + * @throws NumberFormatException if the value is not a number + */ +private fun parseInt(value: String, beginIndex: Int, endIndex: Int): Int { + if (beginIndex < 0 || endIndex > value.length || beginIndex > endIndex) { + throw NumberFormatException(value) + } + // use same logic as in Integer.parseInt() but less generic we're not supporting negative values + var i = beginIndex + var result = 0 + var digit: Int + if (i < endIndex) { + digit = Character.digit(value[i++], 10) + if (digit < 0) { + throw NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)) + } + result = -digit + } + while (i < endIndex) { + digit = Character.digit(value[i++], 10) + if (digit < 0) { + throw NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)) + } + result *= 10 + result -= digit + } + return -result +} + +/** + * Zero pad a number to a specified length + * + * @param buffer buffer to use for padding + * @param value the integer value to pad if necessary. + * @param length the length of the string we should zero pad + */ +private fun padInt(buffer: StringBuilder, value: Int, length: Int) { + val strValue = value.toString() + for (i in length - strValue.length downTo 1) { + buffer.append('0') + } + buffer.append(strValue) +} + +/** + * Returns the index of the first character in the string that is not a digit, starting at offset. + */ +private fun indexOfNonDigit(string: String, offset: Int): Int { + for (i in offset until string.length) { + val c = string[i] + if (c < '0' || c > '9') return i + } + return string.length +} diff --git a/app/src/main/java/com/keylesspalace/tusky/json/Rfc3339DateJsonAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/json/Rfc3339DateJsonAdapter.kt new file mode 100644 index 000000000..090fe5e37 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/json/Rfc3339DateJsonAdapter.kt @@ -0,0 +1,49 @@ +// https://github.com/google/gson/blob/master/extras/src/main/java/com/google/gson/typeadapters/UtcDateTypeAdapter.java +/* + * Copyright (C) 2011 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.keylesspalace.tusky.json + +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.JsonWriter +import java.io.IOException +import java.util.Date + +class Rfc3339DateJsonAdapter : TypeAdapter() { + + @Throws(IOException::class) + override fun write(writer: JsonWriter, date: Date?) { + if (date == null) { + writer.nullValue() + } else { + writer.value(date.formatIsoDate()) + } + } + + @Throws(IOException::class) + override fun read(reader: JsonReader): Date? { + return when (reader.peek()) { + JsonToken.NULL -> { + reader.nextNull() + null + } + else -> { + reader.nextString().parseIsoDate() + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.java b/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.java deleted file mode 100644 index 2dcedd873..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.java +++ /dev/null @@ -1,76 +0,0 @@ -/* Copyright 2018 charlag - * - * 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.network; - -import androidx.annotation.NonNull; - -import com.keylesspalace.tusky.db.AccountEntity; -import com.keylesspalace.tusky.db.AccountManager; - -import java.io.IOException; - -import okhttp3.HttpUrl; -import okhttp3.Interceptor; -import okhttp3.Request; -import okhttp3.Response; - -/** - * Created by charlag on 31/10/17. - */ - -public final class InstanceSwitchAuthInterceptor implements Interceptor { - private AccountManager accountManager; - - public InstanceSwitchAuthInterceptor(AccountManager accountManager) { - this.accountManager = accountManager; - } - - @Override - public Response intercept(@NonNull Chain chain) throws IOException { - - Request originalRequest = chain.request(); - - // only switch domains if the request comes from retrofit - if (originalRequest.url().host().equals(MastodonApi.PLACEHOLDER_DOMAIN)) { - AccountEntity currentAccount = accountManager.getActiveAccount(); - - Request.Builder builder = originalRequest.newBuilder(); - - String instanceHeader = originalRequest.header(MastodonApi.DOMAIN_HEADER); - if (instanceHeader != null) { - // use domain explicitly specified in custom header - builder.url(swapHost(originalRequest.url(), instanceHeader)); - builder.removeHeader(MastodonApi.DOMAIN_HEADER); - } else if (currentAccount != null) { - //use domain of current account - builder.url(swapHost(originalRequest.url(), currentAccount.getDomain())) - .header("Authorization", - String.format("Bearer %s", currentAccount.getAccessToken())); - } - Request newRequest = builder.build(); - - return chain.proceed(newRequest); - - } else { - return chain.proceed(originalRequest); - } - } - - @NonNull - private static HttpUrl swapHost(@NonNull HttpUrl url, @NonNull String host) { - return url.newBuilder().host(host).build(); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt b/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt new file mode 100644 index 000000000..3ca7a8116 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt @@ -0,0 +1,82 @@ +/* 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.network + +import android.util.Log +import com.keylesspalace.tusky.db.AccountManager +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import java.io.IOException + +class InstanceSwitchAuthInterceptor(private val accountManager: AccountManager) : Interceptor { + + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest: Request = chain.request() + + // only switch domains if the request comes from retrofit + return if (originalRequest.url.host == MastodonApi.PLACEHOLDER_DOMAIN) { + + val builder: Request.Builder = originalRequest.newBuilder() + val instanceHeader = originalRequest.header(MastodonApi.DOMAIN_HEADER) + + if (instanceHeader != null) { + // use domain explicitly specified in custom header + builder.url(swapHost(originalRequest.url, instanceHeader)) + builder.removeHeader(MastodonApi.DOMAIN_HEADER) + } else { + val currentAccount = accountManager.activeAccount + + if (currentAccount != null) { + val accessToken = currentAccount.accessToken + if (accessToken.isNotEmpty()) { + // use domain of current account + builder.url(swapHost(originalRequest.url, currentAccount.domain)) + .header("Authorization", "Bearer %s".format(accessToken)) + } + } + } + + val newRequest: Request = builder.build() + + if (MastodonApi.PLACEHOLDER_DOMAIN == newRequest.url.host) { + Log.w("ISAInterceptor", "no user logged in or no domain header specified - can't make request to " + newRequest.url) + return Response.Builder() + .code(400) + .message("Bad Request") + .protocol(Protocol.HTTP_2) + .body("".toResponseBody("text/plain".toMediaType())) + .request(chain.request()) + .build() + } + + chain.proceed(newRequest) + } else { + chain.proceed(originalRequest) + } + } + + companion object { + private fun swapHost(url: HttpUrl, host: String): HttpUrl { + return url.newBuilder().host(host).build() + } + } +} 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 0d9a1945c..e1d18e9f6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.network +import at.connyduck.calladapter.networkresult.NetworkResult import com.keylesspalace.tusky.entity.AccessToken import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Announcement @@ -74,10 +75,10 @@ interface MastodonApi { } @GET("/api/v1/custom_emojis") - suspend fun getCustomEmojis(): Result> + suspend fun getCustomEmojis(): NetworkResult> @GET("api/v1/instance") - suspend fun getInstance(): Result + suspend fun getInstance(): NetworkResult @GET("api/v1/filters") fun getFilters(): Single> @@ -145,7 +146,7 @@ interface MastodonApi { suspend fun updateMedia( @Path("mediaId") mediaId: String, @Field("description") description: String - ): Result + ): NetworkResult @GET("api/v1/media/{mediaId}") suspend fun getMedia( @@ -158,7 +159,7 @@ interface MastodonApi { @Header(DOMAIN_HEADER) domain: String, @Header("Idempotency-Key") idempotencyKey: String, @Body status: NewStatus - ): Result + ): NetworkResult @GET("api/v1/statuses/{id}") fun status( @@ -246,10 +247,13 @@ interface MastodonApi { @DELETE("api/v1/scheduled_statuses/{id}") suspend fun deleteScheduledStatus( @Path("id") scheduledStatusId: String - ): Result + ): NetworkResult @GET("api/v1/accounts/verify_credentials") - suspend fun accountVerifyCredentials(): Result + suspend fun accountVerifyCredentials( + @Header(DOMAIN_HEADER) domain: String? = null, + @Header("Authorization") auth: String? = null, + ): NetworkResult @FormUrlEncoded @PATCH("api/v1/accounts/update_credentials") @@ -274,7 +278,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? - ): Result + ): NetworkResult @GET("api/v1/accounts/search") suspend fun searchAccounts( @@ -282,15 +286,15 @@ interface MastodonApi { @Query("resolve") resolve: Boolean? = null, @Query("limit") limit: Int? = null, @Query("following") following: Boolean? = null - ): Result> + ): NetworkResult> @GET("api/v1/accounts/search") - fun searchAccountsCall( + fun searchAccountsSync( @Query("q") query: String, @Query("resolve") resolve: Boolean? = null, @Query("limit") limit: Int? = null, @Query("following") following: Boolean? = null - ): Call> + ): NetworkResult> @GET("api/v1/accounts/{id}") fun account( @@ -445,7 +449,7 @@ interface MastodonApi { @Field("redirect_uris") redirectUris: String, @Field("scopes") scopes: String, @Field("website") website: String - ): Result + ): NetworkResult @FormUrlEncoded @POST("oauth/token") @@ -456,34 +460,42 @@ interface MastodonApi { @Field("redirect_uri") redirectUri: String, @Field("code") code: String, @Field("grant_type") grantType: String - ): Result + ): NetworkResult + + @FormUrlEncoded + @POST("oauth/revoke") + suspend fun revokeOAuthToken( + @Field("client_id") clientId: String, + @Field("client_secret") clientSecret: String, + @Field("token") token: String + ): NetworkResult @GET("/api/v1/lists") - suspend fun getLists(): Result> + suspend fun getLists(): NetworkResult> @FormUrlEncoded @POST("api/v1/lists") suspend fun createList( @Field("title") title: String - ): Result + ): NetworkResult @FormUrlEncoded @PUT("api/v1/lists/{listId}") suspend fun updateList( @Path("listId") listId: String, @Field("title") title: String - ): Result + ): NetworkResult @DELETE("api/v1/lists/{listId}") suspend fun deleteList( @Path("listId") listId: String - ): Result + ): NetworkResult @GET("api/v1/lists/{listId}/accounts") suspend fun getAccountsInList( @Path("listId") listId: String, @Query("limit") limit: Int - ): Result> + ): NetworkResult> @FormUrlEncoded // @DELETE doesn't support fields @@ -491,20 +503,20 @@ interface MastodonApi { suspend fun deleteAccountFromList( @Path("listId") listId: String, @Field("account_ids[]") accountIds: List - ): Result + ): NetworkResult @FormUrlEncoded @POST("api/v1/lists/{listId}/accounts") suspend fun addAccountToList( @Path("listId") listId: String, @Field("account_ids[]") accountIds: List - ): Result + ): NetworkResult @GET("/api/v1/conversations") suspend fun getConversations( @Query("max_id") maxId: String? = null, - @Query("limit") limit: Int - ): List + @Query("limit") limit: Int? = null + ): Response> @DELETE("/api/v1/conversations/{id}") suspend fun deleteConversation( @@ -547,24 +559,24 @@ interface MastodonApi { @GET("api/v1/announcements") suspend fun listAnnouncements( @Query("with_dismissed") withDismissed: Boolean = true - ): Result> + ): NetworkResult> @POST("api/v1/announcements/{id}/dismiss") suspend fun dismissAnnouncement( @Path("id") announcementId: String - ): Result + ): NetworkResult @PUT("api/v1/announcements/{id}/reactions/{name}") suspend fun addAnnouncementReaction( @Path("id") announcementId: String, @Path("name") name: String - ): Result + ): NetworkResult @DELETE("api/v1/announcements/{id}/reactions/{name}") suspend fun removeAnnouncementReaction( @Path("id") announcementId: String, @Path("name") name: String - ): Result + ): NetworkResult @FormUrlEncoded @POST("api/v1/reports") @@ -601,14 +613,14 @@ interface MastodonApi { ): Single @GET("api/v2/search") - fun searchCall( + fun searchSync( @Query("q") query: String?, @Query("type") type: String? = null, @Query("resolve") resolve: Boolean? = null, @Query("limit") limit: Int? = null, @Query("offset") offset: Int? = null, @Query("following") following: Boolean? = null - ): Call + ): NetworkResult @FormUrlEncoded @POST("api/v1/accounts/{id}/note") @@ -629,7 +641,7 @@ interface MastodonApi { // Should be generated dynamically from all the available notification // types defined in [com.keylesspalace.tusky.entities.Notification.Types] @FieldMap data: Map - ): Result + ): NetworkResult @FormUrlEncoded @PUT("api/v1/push/subscription") @@ -637,11 +649,11 @@ interface MastodonApi { @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String, @FieldMap data: Map - ): Result + ): NetworkResult @DELETE("api/v1/push/subscription") suspend fun unsubscribePushNotifications( @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String, - ): Result + ): NetworkResult } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt index c7e9633f6..a179e71d2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt @@ -1,5 +1,6 @@ package com.keylesspalace.tusky.network +import at.connyduck.calladapter.networkresult.NetworkResult import com.keylesspalace.tusky.entity.MediaUploadResult import okhttp3.MultipartBody import retrofit2.http.Multipart @@ -15,5 +16,5 @@ interface MediaUploadApi { suspend fun uploadMedia( @Part file: MultipartBody.Part, @Part description: MultipartBody.Part? = null - ): Result + ): NetworkResult } 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 7414f4fff..b3c6a72b9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt @@ -15,6 +15,7 @@ import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat +import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.StatusComposedEvent 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 d9e7f4ff4..15238999d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -72,6 +72,6 @@ object PrefKeys { 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_REPLIES = "tabFilterHomeReplies_v2" // This was changed once to reset an unintentionally set default. const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts" } diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt new file mode 100644 index 000000000..f8d3b11ca --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt @@ -0,0 +1,66 @@ +package com.keylesspalace.tusky.usecase + +import android.content.Context +import com.keylesspalace.tusky.components.drafts.DraftHelper +import com.keylesspalace.tusky.components.notifications.NotificationHelper +import com.keylesspalace.tusky.components.notifications.disableUnifiedPushNotificationsForAccount +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.removeShortcut +import javax.inject.Inject + +class LogoutUsecase @Inject constructor( + private val context: Context, + private val api: MastodonApi, + private val db: AppDatabase, + private val accountManager: AccountManager, + private val draftHelper: DraftHelper +) { + + /** + * Logs the current account out and clears all caches associated with it + * @return true if the user is logged in with other accounts, false if it was the only one + */ + suspend fun logout(): Boolean { + accountManager.activeAccount?.let { activeAccount -> + + // invalidate the oauth token, if we have the client id & secret + // (could be missing if user logged in with a previous version of Tusky) + val clientId = activeAccount.clientId + val clientSecret = activeAccount.clientSecret + if (clientId != null && clientSecret != null) { + api.revokeOAuthToken( + clientId = clientId, + clientSecret = clientSecret, + token = activeAccount.accessToken + ) + } + + // disable push notifications + disableUnifiedPushNotificationsForAccount(context, activeAccount) + + // disable pull notifications + if (!NotificationHelper.areNotificationsEnabled(context, accountManager)) { + NotificationHelper.disablePullNotifications(context) + } + + // clear notification channels + NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, context) + + // remove account from local AccountManager + val otherAccountAvailable = accountManager.logActiveAccountOut() != null + + // clear the database - this could trigger network calls so do it last when all tokens are gone + db.timelineDao().removeAll(activeAccount.id) + db.conversationDao().deleteForAccount(activeAccount.id) + draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id) + + // remove shortcut associated with the account + removeShortcut(context, activeAccount) + + return otherAccountAvailable + } + return false + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt similarity index 98% rename from app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt rename to app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt index 86148e51a..8f1144340 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.network +package com.keylesspalace.tusky.usecase import android.util.Log import com.keylesspalace.tusky.appstore.BlockEvent @@ -29,6 +29,7 @@ import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.addTo diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CallExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/CallExtensions.kt deleted file mode 100644 index 809dcd2b4..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/CallExtensions.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.keylesspalace.tusky.util - -import retrofit2.Call -import retrofit2.HttpException - -/** - * Synchronously executes the call and returns the response encapsulated in a kotlin.Result. - * Since Result is an inline class it is not possible to do this with a Retrofit adapter unfortunately. - * More efficient then calling a suspending method with runBlocking - */ -fun Call.result(): Result { - return try { - val response = execute() - val responseBody = response.body() - if (response.isSuccessful && responseBody != null) { - Result.success(responseBody) - } else { - Result.failure(HttpException(response)) - } - } catch (e: Exception) { - Result.failure(e) - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt new file mode 100644 index 000000000..26f962554 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt @@ -0,0 +1,26 @@ +package com.keylesspalace.tusky.util + +import org.json.JSONException +import org.json.JSONObject +import retrofit2.HttpException + +/** + * checks if this throwable indicates an error causes by a 4xx/5xx server response and + * tries to retrieve the error message the server sent + * @return the error message, or null if this is no server error or it had no error message + */ +fun Throwable.getServerErrorMessage(): String? { + if (this is HttpException) { + val errorResponse = response()?.errorBody()?.string() + return if (!errorResponse.isNullOrBlank()) { + try { + JSONObject(errorResponse).getString("error") + } catch (e: JSONException) { + null + } + } else { + null + } + } + return null +} 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 be94369c5..9ea83c252 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -47,8 +47,8 @@ sealed class StatusViewData { get() = status.id /** - * Specifies whether the content of this post is allowed to be collapsed or if it should show - * all content regardless. + * Specifies whether the content of this post is long enough to be automatically + * collapsed or if it should show all content regardless. * * @return Whether the post is collapsible or never collapsed. */ @@ -109,7 +109,7 @@ sealed class StatusViewData { } /** Helper for Java */ - fun copyWIthCollapsed(isCollapsed: Boolean): Concrete { + fun copyWithCollapsed(isCollapsed: Boolean): Concrete { return copy(isCollapsed = isCollapsed) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt index 184debb9b..aafe4ce05 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt @@ -19,6 +19,7 @@ package com.keylesspalace.tusky.viewmodel import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.Either 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 5849582ab..d640b233b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -21,6 +21,7 @@ import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.entity.Account @@ -31,6 +32,7 @@ import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Success +import com.keylesspalace.tusky.util.getServerErrorMessage import com.keylesspalace.tusky.util.randomAlphanumericString import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -38,9 +40,6 @@ import okhttp3.MultipartBody import okhttp3.RequestBody import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody -import org.json.JSONException -import org.json.JSONObject -import retrofit2.HttpException import java.io.File import javax.inject.Inject @@ -155,21 +154,7 @@ class EditProfileViewModel @Inject constructor( 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 { - saveData.postValue(Error()) - } + saveData.postValue(Error(errorMessage = throwable.getServerErrorMessage())) } ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt index 3b5b824b1..4c755f868 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt @@ -18,6 +18,7 @@ package com.keylesspalace.tusky.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.replacedFirstWhich diff --git a/app/src/main/java/net/accelf/yuito/AccessTokenLoginActivity.java b/app/src/main/java/net/accelf/yuito/AccessTokenLoginActivity.java deleted file mode 100644 index ec38e88ba..000000000 --- a/app/src/main/java/net/accelf/yuito/AccessTokenLoginActivity.java +++ /dev/null @@ -1,110 +0,0 @@ -package net.accelf.yuito; - -import android.content.Intent; -import android.os.Bundle; -import android.widget.Button; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatActivity; - -import com.google.android.material.textfield.TextInputEditText; -import com.keylesspalace.tusky.MainActivity; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.db.AccountManager; -import com.keylesspalace.tusky.di.Injectable; - -import java.io.IOException; - -import javax.inject.Inject; - -import okhttp3.Call; -import okhttp3.Callback; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; - -public class AccessTokenLoginActivity extends AppCompatActivity implements Injectable { - - @Inject - AccountManager accountManager; - - TextInputEditText domainEditText; - TextInputEditText accessTokenEditText; - TextView logTextView; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_access_token_login); - - domainEditText = findViewById(R.id.domainEditText); - accessTokenEditText = findViewById(R.id.accessTokenEditText); - Button authorizeButton = findViewById(R.id.authorizeButton); - logTextView = findViewById(R.id.logTextView); - - authorizeButton.setOnClickListener(v -> authorize()); - log("Input domain and access token to login."); - } - - private void log(String text) { - runOnUiThread(() -> logTextView.setText(String.format("%s\n%s", logTextView.getText().toString(), text))); - } - - private void authorize() { - if (domainEditText.getText() != null) { - String domain = domainEditText.getText().toString(); - String accessToken = accessTokenEditText.getText().toString(); - HttpUrl url; - - log("Starting login test. [domain: " + domain + ", accessToken: " + accessToken + "]"); - - try { - url = new HttpUrl.Builder().host(domain).scheme("https") - .addPathSegments("/api/v1/accounts/verify_credentials") - .addQueryParameter("access_token", accessToken) - .build(); - } catch (IllegalArgumentException e) { - log("Wrong domain format. " + e.getMessage()); - log("Aborting."); - return; - } - - log("Access start -> " + url.toString()); - - OkHttpClient okHttpClient = new OkHttpClient(); - Request request = new Request.Builder().url(url).get().build(); - okHttpClient.newCall(request).enqueue(new Callback() { - @Override - public void onFailure(@NonNull Call call, @NonNull IOException e) { - log("Login failed. " + e.getMessage()); - log("Aborting."); - } - - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException { - if (response.body() != null) { - log(response.body().string()); - } - if (response.code() != 200) { - throw new IOException("Invalid response code. Response code was " + response.code()); - } - log("Login successful. Moving to account registration phase."); - authSucceeded(domain, accessToken); - } - }); - } - } - - private void authSucceeded(String domain, String accessToken) { - accountManager.addAccount(accessToken, domain, ""); - log("Completed. Enjoy!"); - - Intent intent = new Intent(this, MainActivity.class); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - startActivity(intent); - finish(); - overridePendingTransition(R.anim.explode, R.anim.explode); - } -} diff --git a/app/src/main/java/net/accelf/yuito/AccessTokenLoginActivity.kt b/app/src/main/java/net/accelf/yuito/AccessTokenLoginActivity.kt new file mode 100644 index 000000000..9e50b86b0 --- /dev/null +++ b/app/src/main/java/net/accelf/yuito/AccessTokenLoginActivity.kt @@ -0,0 +1,71 @@ +package net.accelf.yuito + +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import at.connyduck.calladapter.networkresult.onFailure +import at.connyduck.calladapter.networkresult.onSuccess +import com.keylesspalace.tusky.MainActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ActivityAccessTokenLoginBinding +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.viewBinding +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +class AccessTokenLoginActivity : AppCompatActivity(), Injectable { + + @Inject + lateinit var accountManager: AccountManager + + @Inject + lateinit var mastodonApi: MastodonApi + + private val binding by viewBinding(ActivityAccessTokenLoginBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + + binding.authorizeButton.setOnClickListener { + it.isEnabled = false + runBlocking { authorize() } + it.isEnabled = true + } + + log("Input domain and access token to login.") + } + + private fun log(text: String) { + runOnUiThread { + binding.logTextView.text = String.format("%s\n%s", binding.logTextView.text.toString(), text) + } + } + + private suspend fun authorize() { + if (binding.domainEditText.text.isNullOrBlank()) { + return + } + + val domain = binding.domainEditText.text.toString() + val accessToken = binding.accessTokenEditText.text.toString() + log("Starting login test. [domain: $domain, accessToken: $accessToken]") + mastodonApi.accountVerifyCredentials(domain, auth = "Bearer $accessToken") + .onSuccess { account -> + log("Login successful. Moving to account registration phase.") + accountManager.addAccount(accessToken, domain, "", "", "", account) + log("Completed. Enjoy!") + 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) + } + .onFailure { e -> + log("Login failed. ${e.message}") + log("Aborting.") + } + } +} diff --git a/app/src/main/java/net/accelf/yuito/FooterDrawerItem.kt b/app/src/main/java/net/accelf/yuito/FooterDrawerItem.kt index 5e919cd53..661d858d0 100644 --- a/app/src/main/java/net/accelf/yuito/FooterDrawerItem.kt +++ b/app/src/main/java/net/accelf/yuito/FooterDrawerItem.kt @@ -4,6 +4,9 @@ import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import at.connyduck.calladapter.networkresult.NetworkResult +import at.connyduck.calladapter.networkresult.onFailure +import at.connyduck.calladapter.networkresult.onSuccess import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemDrawerFooterBinding import com.keylesspalace.tusky.entity.Instance @@ -34,10 +37,10 @@ class FooterDrawerItem : AbstractDrawerItem = throw UnsupportedOperationException() - fun setInstance(instance: Result) { + fun setInstance(instance: NetworkResult) { instance .onSuccess { - binding.instanceData.text = String.format("%s\n%s\n%s", it.title, it.uri, it.version) + binding.instanceData.text = listOf(it.title, it.uri, it.version).joinToString("\n") } .onFailure { binding.instanceData.text = binding.root.context.getString(R.string.instance_data_failed) diff --git a/app/src/main/res/layout/activity_compose.xml b/app/src/main/res/layout/activity_compose.xml index f4a7d1cdc..e4a626fec 100644 --- a/app/src/main/res/layout/activity_compose.xml +++ b/app/src/main/res/layout/activity_compose.xml @@ -297,6 +297,7 @@ app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" /> + + + + Výchozí soukromí příspěvků Vždy označovat média jako citlivá Publikování (synchronizováno se serverem) - Nepodařilo se synchronizovsat nastavení + Nepodařilo se synchronizovat nastavení Veřejné Neuvedené Pouze pro sledující @@ -483,4 +483,12 @@ Zobrazit dialogové okno s potvrzením při boostování %s právě vydal Oznámení + Přihlášení + %s se zaregistroval + Přihlaste se znovu pro oznámení + Nepodařilo se načíst stránku přihlášení. + Tento příspěvek se nepodařilo poslat! + Nepodařilo se načíst detaily účtu + Nepodařilo se načíst informace o odpovědi + Obrázek se nepodařilo upravit. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index cef8b8169..30c0243df 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -536,4 +536,9 @@ Anmelden Die Anmeldeseite konnte nicht geladen werden. Beitragsbearbeitungen + Neuanmeldung für Push-Benachrichtigungen + Ablehnen + Du hast dich erneut in dein aktuelles Konto eingeloggt, um Tusky die Genehmigung für Push-Abonnements zu erteilen. Du hast jedoch noch andere Konten, die nicht auf diese Weise migriert wurden. Wechsel zu diesen Konten und melde dich nacheinander neu an, um die Unterstützung für UnifiedPush-Benachrichtigungen zu aktivieren. + Um Push-Benachrichtigungen über UnifiedPush verwenden zu können, benötigt Tusky die Erlaubnis, Benachrichtigungen auf Ihrem Mastodon-Server zu abonnieren. Dies erfordert eine erneute Anmeldung, um die Tusky gewährten OAuth-Bereiche zu ändern. Wenn du die Option zum erneuten Einloggen hier oder in den Kontoeinstellungen verwendest, bleiben alle deine lokalen Entwürfe und der Cache erhalten. + Melde alle Konten neu an, um die Unterstützung für Push-Benachrichtigungen zu aktivieren. diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 5585f2d11..759ea7fdc 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -8,22 +8,22 @@ خطای احراز هویت ناشناخته‌ای رخ داد. احراز هویت رد شد. دریافت ژتون ورود شکست خورد. - وضعیت خیلی طولانی است! + فرسته خیلی طولانی است! پرونده باید کمتر از ۸ مگابایت باشد. پرونده ویدئویی باید کمتر از ۴۰ مگابایت باشد. این گونهٔ پرونده نمی‌تواند بارگذاری شود. این پرونده نتوانست گشوده شود. نیاز به اجازهٔ خواندن رسانه است. نیاز به اجازهٔ ذخیرهٔ رسانه است. - تصاویر و فیلم‌ها هر دو نمی‌توانند به یک وضعیت ضمیمه شوند. + تصاویر و فیلم‌ها نمی‌توانند به یک فرسته پیوست شوند. بارگذاری شکست خورد. - خطای فرستادن بوق. + خطای فرستادن فرسته. خانه آگاهی‌ها محلّی همگانی - بوق - فرسته + رشته + فرسته‌ها با پاسخ‌ دنبال شونده پی‌گیر @@ -41,10 +41,10 @@ نمایش بیش‌تر نمایش کم‌تر گسترش - بستن + جمع کردن این‌جا هیچ‌چیز نیست. برای تازه‌سازی، به پایین بکشید! - %s بوقتان را تقویت کرد - %s بوقتان را برگزید + %s فرسته‌تان را تقویت کرد + %s فرسته‌تان را برگزید %s پی‌گیرتان شد گزارش @%s نظرهای اضافی؟ @@ -93,13 +93,13 @@ رد جست‌وجو پیش‌نویس‌ها - نمایانی بوق + نمایانی فرسته هشدار محتوا صفحه‌کلید اموجی درحال بارگیری %1$s رونوشت از پیوند - هم‌رسانی نشانی بوق با… - هم‌رسانی بوق با… + هم‌رسانی نشانی فرسته با… + هم‌رسانی فرسته با… هم‌رسانی رسانه با… فرستاده شد! کاربرنامسدود شد @@ -130,7 +130,7 @@ بارگیری درخواست دنبال کردن را لغو می‌کنید؟ ناپیگیری این حساب؟ - حذف این بوق؟ + حذف این فرسته؟ عمومی: فرستادن به خط زمانی‌های عمومی فهرست‌نشده: نشان ندادن در خط زمانی‌های عمومی تنها دنبال‌کنندگان:پست فقط به دنبال‌کنندگان @@ -156,7 +156,7 @@ مرورگر استفاده از زبانه‌های سفارشی کروم نهفتن دکمهٔ ایجاد، هنگام پیمایش - فیلتر کردن خط زمانی + پالایش خط زمانی زبانه‌ها نمایش تقویت‌ها نمایش پاسخ‌ها @@ -173,10 +173,10 @@ عمومی فهرست‌نشده فقط پی‌گیران - اندازهٔ متن وضعیت + اندازهٔ متن فرسته کوچک‌ترین کوچک - متوسط + میانه بزرگ بزرگ‌ترین اشاره‌های جدید @@ -184,9 +184,9 @@ پی‌گیران جدید آگاهی‌ها دربارهٔ پی‌گیران جدید تقویت‌ها - آگاهی‌ها هنگام تقویت شدن بوق‌هایتان + آگاهی‌ها هنگام تقویت فرسته‌هایتان برگزیدن‌ها - آگاهی‌ها هنگام برگزیده شدن بوق‌هایتان + آگاهی‌ها هنگام برگزیده شدن فرسته‌هایتان %s به شما اشاره کرد %1$s، %2$s، %3$s و %4$d دیگر %1$s، %2$s و %3$s @@ -209,8 +209,8 @@ گزارش مشکلات و درخواست ویژگی‌ها: \n https://github.com/accelforce/Yuito/issues نمایهٔ تاسکی - هم‌رسانی محتوای بوق - هم‌رسانی پیوند بوق + هم‌رسانی محتوای فرسته + هم‌رسانی پیوند فرسته تصویرها ویدیو تقاضای پیگیری شد @@ -240,19 +240,19 @@ قفل حساب لازم است پی‌گیران را دستی تأیید کنید ذخیرهٔ پیش‌نویس؟ - در حال فرستادن بوق… - خطای فرستادن بوق - در حال فرستادن بوق‌ها + فرستادن فرسته… + خطا در فرستادن فرسته + فرستادن فرسته‌ها فرستادن لغو شد - رونوشتی از بوق در پیش‌نویس‌هایتان ذخیره شد + رونوشتی از فرسته در پیش‌نویس‌هایتان ذخیره شد ایجاد نمونه‌تان %s هیچ اموجی سفارشی‌ای ندارد سبک اموجی پیش‌گزیدهٔ سامانه نخست باید این مجموعه‌های اموجی را بارگیری کنید در حال جست‌وجو… - گسترده/جمع کردن تمام وضعیت‌ها - گشودن بوق + گسترش/جمع کردن تمام فرسته‌ها + گشودن فرسته نیاز به آغاز دوبارهٔ کاره برای اعمال این تغییرات، نیاز به شروع دوبارهٔ تاسکی دارید بعداً @@ -278,7 +278,7 @@ یک خطای شبکه رخ داد! لطفا اتصال خود را بررسی و دوباره تلاش کنید! پیام‌های مستقیم زبانه‌ها - سنجاق‌شده + سنجاق شده دامنه‌های نهفته \@%s این‌جا هیچ‌چیزی نیست. @@ -304,7 +304,7 @@ بارگیری رسانه در حال بارگیری رسانه %s نانهفته - می‌خواهید این بوق را پاک و بازنویسی کنید؟ + حذف و بازنویسی این فرسته؟ نهفتن تمام دامنه پایان نظرسنجی‌ها پالایه‌ها @@ -320,7 +320,7 @@ %d ساعت %d دقیقه %d ثانیه - گسترش همیشگی بوق‌های علامت‌خورده با هشدار محتوا + گسترش همیشگی فرسته‌های علامت‌خورده با هشدار محتوا خط زمانی‌های عمومی گفت‌وگوها افزودن پالایه @@ -360,8 +360,8 @@ رسانه: %s هشدار محتوا: %s - بدون هیچ توضیحی - بازبوقیده + بدون شرح + تقویت شده برگزیده عمومی فهرست‌نشده @@ -373,7 +373,7 @@ پاک‌سازی پالایش اعمال - ایجاد بوق + ایجاد فرسته ایجاد مطمئنید می‌خواهید تمام آگاهی‌هایتان را برای همیشه پاک کنید؟ پایان در %s @@ -400,7 +400,7 @@ نظرهای اضافی هدایت به %s شکست در گزارش - شکست در واکشی وضعیت‌ها + شکست در واکشی فرسته‌ها حساب‌ها شکست در جست‌وجو نمایش پالایهٔ آگاهی‌ها @@ -416,10 +416,10 @@ گزینه‌های چندگانه گزینهٔ %d ویرایش - بوق‌های زمان‌بسته + فرسته‌های زمان‌بسته ویرایش - بوق‌های زمان‌بسته - بوق زمان‌بسته + فرسته‌های زمان‌بسته + فرستهٔ زمان‌بسته بازنشانی مطمئنید می‌خواهید تمام %s را مسدود کنید؟ محتوای آن دامنه را در هیچ‌یک از خط زمانی‌ها یا در آگاهی‌هایتان نخواهید دید. پی‌گیرانتان از آن دامنه، برداشته خواهند شد. هنگامی که کلیدواژه یا عبارت، فقط حروف‌عددی باشد، فقط اگر با تمام واژه مطابق باشد، اعمال خواهد شد @@ -437,7 +437,7 @@ گزینش فهرست فهرست هیچ پیش‌نویسی ندارید. - هیچ وضعیت زمان‌بسته‌ای ندارید. + هیچ فرستهٔ زمان‌بسته‌ای ندارید. ماستودون، بازهٔ زمان‌بندی‌ای با کمینهٔ ۵ دقیقه دارد. نمایش گفت‌وگوی تأیید، پیش از تقویت پیش‌نمایش پیوندها در خط‌زمانی‌ها @@ -482,7 +482,7 @@ عدم اشتراک اشتراک پیش‌نویس حذف شد - فرستادن این بوق شکست خورد! + فرستادن این فرسته شکست خورد! نهفتن آمار کمی روی نمایه‌ها نهفتن آمار کمی روی فرسته‌ها محدود کردن آگاهی‌های خط‌زمانی @@ -491,17 +491,17 @@ طول پیوست‌ها صدا - آگاهی‌ها هنگام انتشار بوقی جدید از کسی که مشترکش هستید - بوق‌های جدید + آگاهی‌ها هنگام انتشار فرسته‌ای جدید از کسی که پی‌می‌گیرید + فرسته‌های جدید اموجی‌های شخصی متحرّک - کسی که مشترکش شده‌ام، بوقی جدید منتشر کرد + کسی که پی‌می‌گیرم، فرسته‌ای جدید منتشر کرد %s چیزی فرستاد - بوقی که پاسخی به آن را پیش‌نویس کردید، برداشته شده + فرسته‌ای که پاسخی به آن را پیش‌نویس کردید، برداشته شده شکست در بار کردن اطّلاعات پاسخ برخی اطّلاعات که ممکن است روی سلامتی ذهنیتان تأثیر بگذارد، پنهان خواهند شد. همچون: \n \n - آگاهی‌های برگزیدن، تقویت و پی‌گیری -\n - شمار برگزیدن و تقویت بوق‌ها +\n - شمار برگزیدن و تقویت فرسته‌ها \n - آمار پی‌گیر و فرسته روی نمایه‌ها \n \n فرستادن آگاهی‌ها تأثیر نمی‌پذیرد، ولی می‌توانید ترجیحات آگاهیتان را به صورت دستی بازبینی کنید. @@ -514,4 +514,34 @@ با این که حسابتان قفل نیست، کارکنان %1$s فکر کردند ممکن است بخواهید درخواست‌های پی‌گیری از این حساب‌ها را دستی بازبینی کنید. حذف این گفت‌وگو؟ حذف گفت‌وگو + در %1$s پیوست + ورود + %s ثبت‌نام کرد + نمایش گفت‌وگوی تأیید پیش از برگزیدن + ایجاد فرسته + ورود دوباره به تمامی حساب‌ها برای به کار انداختن پشتیبانی آگاهی‌های ارسالی. + آگاهی‌ها هنگام ویرایش فرسته‌هایی که با آن‌ها تعامل داشته‌اید + برداشن نشانک + برای اعطای اجازهٔ اشتراک آگاهی‌های ارسالی ، دوباره به حسابتان وارد شدید. با این حال هنوز حساب‌هایی دیگر دارید که این‌گونه مهاجرت داده نشده‌اند. به آن‌ها رفته و برای به کار انداختن پشتیبانی آگاهی‌های UnifiedPush یکی‌یکی دوباره وارد شوید. + مطمئنید که می‌خواهید از حساب %1$s خارج شوید؟ + ۱۴ روز + ۳۰ روز + ۶۰ روز + ۹۰ روز + ۳۶۵ روز + ۱۸۰ روز + ۱+ + تاسکی برای استفاده از آگاهی‌های ارسالی با UnifiedPush نیاز به اجازهٔ اشتراک آگاهی‌ها روی کارساز ماستودنتان دارد. این کار نیازمند ورود دوباره برای تغییر حوزه‌های OAuth اعطایی به تاسکی است. استفاده از گزینهٔ ورود دوباره در این‌جا یا در ترجیحات حساب، تمامی انباره‌ها و پیش‌نویس‌های محلیتان را نگه خواهد داشت. + نتوانست صفحهٔ ورود را بار کند. + کسی ثبت‌نام کرد + ویرایش‌های فرسته + ویرایش تصویر + %s فرسته‌اش را ویراست + فرسته‌ای که با آن تعامل داشته‌ام ویرایش شده + ثبت‌نام‌ها + آگاهی‌ها دربارهٔ کاربران جدید + ورود دوباره برای آگاهی‌های ارسالی + رد کردن + جزییات + ذخیرهٔ پیش‌نویس… diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index bdc3a4e01..30dc16258 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -27,7 +27,7 @@ Onglets Fil Messages - Pouets & réponses + Avec réponses Épinglés Abonnements Abonné·e·s @@ -42,7 +42,7 @@ %s a partagé Contenu sensible Média caché - Cliquer pour voir + Appuyer pour voir Voir plus Voir moins Déplier @@ -156,7 +156,7 @@ Télécharger Révoquer la demande d’abonnement ? Ne plus suivre ce compte ? - Supprimer ce pouet ? + Supprimer ce message \? Public : afficher dans les fils publics Non listé : ne pas afficher dans les fils publics Abonné·e·s uniquement : seul·e·s vos abonné·e·s verront vos statuts @@ -202,7 +202,7 @@ Public Non listé Abonné·e·s uniquement - Taille du texte pour les statuts + Taille du texte des messages Plus petit Petit Moyen @@ -243,17 +243,17 @@ https://github.com/accelforce/Yuito/issues Profil de Yuito - Partager le contenu du pouet - Partager le lien du pouet + Partager le contenu du message + Partager le lien du message Images Vidéo Demande d’abonnement effectuée - en %da - en %dj - en %d h - en %dm - en %ds + dans %da + dans %dj + dans %dh + dans %dm + dans %ds %d a %dj %d h @@ -264,7 +264,7 @@ Média Réponse à @%s en charger plus - Timelines publiques + Fils publics Conversations Ajouter un filtre Modifier un filtre @@ -296,19 +296,19 @@ Verrouiller le compte Vous devez approuver manuellement les abonnements Enregistrer comme brouillon ? - Envoi du pouet… - Erreur lors de l’envoi du pouet - Envoi des pouets + Envoi du message… + Erreur lors de l’envoi du message + Envoi des messages Envoi annulé - Une copie du pouet a été sauvegardée dans vos brouillons + Une copie du message a été sauvegardée dans vos brouillons Écrire Votre instance %s n’a pas d’émojis personnalisés Style d’émojis Par défaut du système Vous devez commencer par télécharger ces jeux d’émojis Recherche en cours… - Déplier/replier tout les statuts - Ouvrir le pouet + Déplier/replier tout les messages + Ouvrir le message Un redémarrage de l’application est nécessaire Vous devrez redémarrer Yuito pour appliquer ces modifications Plus tard @@ -350,16 +350,12 @@ maximum de %1$d onglet atteint maximum de %1$d onglets atteint - Média : %s - + Média : %s Avertissement : %s - Pas de description - - Reblogué - - Mis en favoris - + Aucune description + Partagé + Mis en favoris Public Non listé @@ -377,7 +373,7 @@ Afficher l\'indicateur de robots Désirez-vous nettoyer toutes vos notifications de façon permanente \? Effacer et ré-écrire - Effacer et ré-écrire ce pouet \? + Effacer et ré-écrire ce message \? Termina à %s Terminé Voter @@ -390,15 +386,15 @@ %d jours restants - %d heure restant + %d heure restante %d heures restantes - %d minute restant + %d minute restante %d minutes restantes - %d seconde restant + %d seconde restante %d secondes restantes Activer l’animation des avatars @@ -418,7 +414,7 @@ Commentaires additionnels Transférer à %s Échec du signalement - Échec de récupération des statuts + Échec de récupération des messages Le rapport sera envoyé aux modérateur·rice·s de votre instance. Vous pouvez expliquer pourquoi vous signalez le compte ci-dessous : Êtes-vous sûr⋅e de vouloir bloquer %s en entier \? Vous ne verrez plus de contenu provenant de ce domaine, ni dans les fils publics, ni dans vos notifications. Vos abonné·e·s utilisant ce domaine seront retiré·e·s. Terminé @@ -444,8 +440,8 @@ Éditer Pouets planifiés Éditer - Pouets programmés - Planifier le pouet + Messages programmés + Planifier le message Réinitialiser Erreur lors de la recherche du post %s Propulsé par Tusky @@ -457,7 +453,7 @@ Liste Les fichiers audio doivent avoir moins de 40 Mo. Vous n’avez aucun brouillon. - Vous n’avez aucun pouet planifié. + Vous n’avez aucun message planifié. L’intervalle minimum de planification sur Mastodon est de 5 minutes. Demandes d\'abonnement Bloquer @%s \? @@ -528,7 +524,7 @@ Audio Demander confirmation avant de mettre en favoris Le message auquel répondait ce brouillon a été supprimé - Échec d’envoi du pouet ! + Échec d’envoi du message ! Bien que votre compte ne soit pas verrouillé, l’équipe de %1$s a pensé que vous voudriez valider manuellement les demandes de d’abonnement provenant de ces comptes. Échec du chargement des informations de réponse 30 jours @@ -540,11 +536,23 @@ Rédiger un message %s a créé un compte Nouveaux comptes - Notifications quand quelqu\'un crée un nouveau compte + 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é + un message avec lequel j’ai interagi est modifié Messages modifiés - Notifications quand un post avec lequel vous avez interagi est modifié + Notifications quand un message avec lequel vous avez interagi est modifié Se connecter + Ici depuis %1$s + Détails + Sauvegarde du brouillon … + >1 + Tusky peut maintenant recevoir les notifications instantanées de ce compte. Cependant, d\'autres de vos comptes n\'ont pas encore accès aux notifications instantanées. Basculez sur chacun de vos comptes et reconnectez les afin de recevoir les notifications avec UnifiedPush. + La page de connexion ne peut être chargée. + Retoucher l’image + Fermer + Se reconnecter pour recevoir les notifications instantanées + Afin de recevoir les notifications via UnifiedPush, Tusky doit demander à votre serveur Mastodon la permission de s’inscrire aux notifications. Ceci nécessite une reconnexion de vos comptes afin de changer les droits OAuth accordés a Tusky. En utilisant l’option de reconnexion ici ou dans les préférences de compte, vos brouillons et le cache seront préservés. + Reconnectez tous vos comptes pour activer les notifications instantanées. + L\'image n’a pas pu être retouchée. diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index 39e7d5a32..294bdc56a 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -556,4 +556,11 @@ Clàraich a-steach Cha b’ urrainn dhuinn duilleag a’ chlàraidh a-steach fhosgladh. A’ sàbhaladh na dreuchd… + Leig seachad + Fiosrachadh + Air ballrachd fhaighinn %1$s + Clàraich a-steach às ùr leis a h-uile cunntas a chur na taice ri brathan putaidh an comas. + Clàraich a-steach às ùr airson brathan putaidh + 1+ + Deasaich an dealbh \ 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 2b6be45eb..af350478c 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -52,8 +52,8 @@ Bloquear Deixar de seguir Seguir - Tes a certeza de que queres desconectar a conta %1$s\? - Desconectar + Tes a certeza de que queres pechar sesión da conta %1$s\? + Pechar sesión Accede con Mastodon Redactar Máis @@ -521,4 +521,20 @@ hai unha nova usuaria Rexistros Notificacións sobre novas usuarias + Foi editada unha publicación coa que interactuei + Edicións da publicación + Creada %1$s + Volve a acceder con tódalas contas para activar as notificacións push. + Acceder + Notificacións cando son editadas publicacións coas que interactuaches + Para poder usar as notificacións push vía UnifiedPush, Tusky require o permiso para subscribirse ás notificacións do teu servidor Mastodon. É necesario volver a acceder para cambiar os ámbitos OAuth concedidos a Tusky. Usando aquí ou nas Preferencias da Conta a opción de volver a acceder conservarás os borradores locais e caché. + Volveches a acceder para obter as notificacións push en Tusky. Aínda así tes algunha outra conta que non foi migrada a este modo. Cambia a esas contas e volve a conectar unha a unha para activar o soporte para notificacións de UnifiedPush. + Volve a acceder para ter notificacións push + %s editou a publicación + Desbotar + Detalles + Non se puido cargar a páxina de inicio. + Gardando borrador… + 1+ + Editar imaxe \ No newline at end of file diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index faa9b2ce8..45075ca8d 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -533,4 +533,18 @@ Bejegyzések szerkesztése Értesítések olyan bejegyzések szerkesztéséről, melyekkel már dolgod volt Bejegyzés Létrehozása + Bejelentkezés újra a leküldési értesítések érdekében + Elvetés + Részletek + Csatlakozva %1$s + Bejelentkezés újra minden fiókkal a leküldéses értesítések engedélyezése érdekében. + Bejelentkezés + 1+ + Nem tudtuk betölteni a bejelentkező oldalt. + Vázlat mentése… + Ahhoz, hogy használhass leküldési értesítéseket a UnifiedPush szolgáltatással, a Tusky-nak fel kell iratkoznia az értesítésekre a Mastodon szervereden. Ehhez új bejelentkezésre van szükség, hogy a Tusky számára kiosztott OAuth jogosultságok megváltozzanak. Az újbóli bejelentkezés funkció használata itt vagy a Fiókbeállításoknál meg fogja őrizni a helyi piszkozataidat és a cache tartalmát. + Újra bejelentkeztél a fiókodba, hogy feliratkoztasd a Tusky-t a leküldési értesítések használatára. Ugyanakkor vannak még fiókjaid, melyek még nem lettek így migrálva. Válts át rájuk és jelentkezz be újra mindegyikben, hogy ezekben is engedélyezd a UnifiedPush értesítések támogatását. + Kép szerkesztése + A kép nem szerkeszthető. + Nem sikerült betölteni a fiókadatokat diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index 0177ee5c1..77350a872 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -525,4 +525,16 @@ Tilkynningar um nýja notendur Breytingar á færslum Tilkynningar þegar færslum sem þú hefur átt við er breytt + Þú hefur skráð þig aftur inn í fyrirliggjandi aðganginn þinn til þess að veita heimild fyrir áskrift að ýti-tilkynningum í Tusky. Aftur á móti ertu með aðra aðganga sem ekki hafa verið yfirfærðir á þennan hátt. Skiptu yfir í þá og skráðu þig þar inn aftur til að virkja stuðning við tilkynningar í gegnum UnifiedPush. + Skráði sig %1$s + Skrá aftur inn alla aðganga til að virkja stuðning við ýti-tilkynningar. + Til þess að geta sent ýti-tilkynningar í gegnum UnifiedPush, þarf Tusky heimild til að gerast áskrifandi að tilkynningum á Mastodon-netþjóninum þínum. Þetta krefst þess að skráð sé inn aftur til að breyta vægi OAuth-heimilda sem Tusky er úthlutað. Notaðu endurinnskráninguna hérna eða í kjörstillingum aðgangsins þíns til að varðveita öll drögin þín og skyndiminni á tækinu. + Skrá inn + 1+ + Gat ekki lesið innskráningarsíðuna. + Breyta mynd + Vista drög… + Skráðu aftur inn fyrir ýti-tilkynningar + Hunsa + Nánar \ 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 a55b5f223..541996b51 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -30,7 +30,7 @@ Con risposte Fissati Seguiti - Seguono + Seguaci Preferiti Utenti silenziati Utenti bloccati @@ -39,7 +39,7 @@ Bozze Licenze \@%s - %s ha boostato + %s ha condiviso Contenuto sensibile Media nascosto Clicca per visualizzare @@ -49,15 +49,15 @@ Riduci Qui non c\'è nulla. Qui non c\'è nulla. Trascina verso il basso per aggiornare! - %s ha boostato il tuo post + %s ha condiviso il tuo post %s ha messo il tuo post nei preferiti %s ti ha seguito Segnala @%s Commenti aggiuntivi? Risposta veloce Rispondi - Boosta - Rimuovi boost + Condividi + Rimuovi condivisione Aggiungi ai preferiti Rimuovi preferito Di più @@ -69,8 +69,8 @@ Smetti di seguire Blocca Sblocca - Nascondi boost - Mostra boost + Nascondi condivisioni + Mostra condivisioni Segnala Elimina TOOT @@ -109,8 +109,8 @@ Collegamenti Menzioni Hashtag - Vai all\'autore del boost - Mostra boost + Vai all\'autore della condivisione + Mostra condivisioni Mostra preferiti Hashtag Menzioni @@ -153,8 +153,8 @@ Revocare la richiesta di seguire? Smettere di seguire questo account? Eliminare questo post\? - Pubblico: visibile sulla timeline pubblica - Non in elenco: non visibile sulla timeline pubblica e locale + Pubblico: visibile sulle timeline pubbliche + Non in elenco: non visibile sulle timeline pubbliche Solo follower: visibile solo dai tuoi follower Diretto: visibile solo agli utenti menzionati Notifiche @@ -166,7 +166,7 @@ Notificami quando vengo menzionato vengo seguito - i miei post vengono boostati + i miei post vengono condivisi i miei post vengono messi nei preferiti Aspetto Tema dell\'app @@ -183,7 +183,7 @@ Lingua Filtraggio della timeline Schede - Mostra boost + Mostra condivisioni Mostra risposte Mostra anteprime media Proxy @@ -208,8 +208,8 @@ Notifiche di quando vieni menzionato da qualcuno Nuovi follower Notifiche su nuovi follower - Boost - Notifiche sui tuoi post che vengono boostati + Condivisioni + Notifiche sui tuoi post che vengono condivisi Preferiti Notifiche sui tuoi post che vengono segnati come preferiti %s ti ha menzionato @@ -312,8 +312,8 @@ Download fallito Bot %1$s si è spostato su: - Boost con la visibilità del post di origine - Annulla boost + Condividi con la visibilità del post originale + Annulla condivisione Yuito contiene codice e risorse dai seguenti progetti open source: Licenziata sotto la Licenza Apache (copia sotto) CC-BY 4.0 @@ -334,7 +334,7 @@ <b>%s</b> Boost <b>%s</b> Boost - Boostato da + Condiviso da Aggiunto ai preferiti da %1$s %1$s e %2$s @@ -402,7 +402,7 @@ Scegli lista Lista Azioni per l\'immagine %s - Un sondaggio che hai votato si è concluso + Un sondaggio in cui hai votato si è concluso Un sondaggio che hai creato si è concluso %d giorno rimasto @@ -470,7 +470,7 @@ Salvato! La tua nota privata su questo account Nascondi il titolo della barra degli strumenti in alto - Mostra la finestra di conferma prima di boostare + Mostra la finestra di conferma prima di condividere Mostra le anteprime dei collegamenti nelle timelines Mastodon ha un intervallo di programmazione minimo di 5 minuti. Non ci sono annunci. @@ -488,7 +488,7 @@ mi viene richiesto di seguirmi Nascondi statistiche quantitative sui profili Nascondi le statistiche quantitative sui post - Limita le notifiche dalla timeline + Limita notifiche riguardo statistiche quantitative Rivedi le notifiche Benessere Notifiche di nuovi post di qualcuno a cui sei iscritto @@ -515,13 +515,13 @@ 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 post +\n - Notifiche riguardo a Preferiti/Condivisioni/Following +\n - Conteggio dei Preferiti/Condivisioni nei post \n - Statistiche riguardo a Preferiti/Post nei profili \n \n Le notifiche push non saranno influenzate, ma puoi modificare le tue impostazioni delle notifiche manualmente. Rimuovi segnalibro - Chiedi conferma prima di boostare + Chiedi conferma prima di condividere 14 giorni 30 giorni 60 giorni @@ -540,4 +540,8 @@ Modifiche ai post Notifiche di quando i post con cui hai interagito vengono modificati Non è stato possibile caricare la pagina di login. + Modifica immagine + Salvataggio bozza… + Scartare + Dettagli diff --git a/app/src/main/res/values-no-rNB/strings.xml b/app/src/main/res/values-no-rNB/strings.xml index 1561efa98..13123f059 100644 --- a/app/src/main/res/values-no-rNB/strings.xml +++ b/app/src/main/res/values-no-rNB/strings.xml @@ -527,4 +527,16 @@ Varslinger når et innlegg du har hatt en interaksjon med er redigert Innlogging Klarte ikke å laste innloggingssiden. + Logg inn på nytt for pushvarsler + Avvis + Detaljer + Ble med %1$s + Logg inn all konti på nytt for å skru på pushvarsler. + For å kunne sende pushvarsler via UnifiedPush trenger Tusky tillatelse til å abonnere på varsler på Mastodon-serveren. Dette krever at du logger inn på nytt. Ved å bruke muligheten til å logge inn på nytt her eller i kontoinstillinger vil alle lokale kladder være tilgjengelig også etter at du har logget inn på nytt. + Du har logget inn på nytt for å tillate Tusky til å sende pushvarsler, men du har fortsatt andre konti som ikke har fått den nødvendige tillatelsen. Bytt til dem og logg inn på nytt på samme måte for å skru på støtte for pushvarsler via UnifiedPush. + Lagrer kladd… + 1+ + Rediger bilde + Bildet kunne ikke redigeres. + Lasting av kontodetaljer feilet diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index fa4b458e3..e7d565590 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -65,10 +65,10 @@ Anúncios Licenças \@%s - %s fez boost + %s deu boost Nada aqui. Nada para ver aqui. Arraste para baixo para atualizar! - %s fez boost ao seu toot + %s deu boost ao seu toot %s adicionou o seu toot aos favoritos %s está a seguir-te %s pediu para te seguir @@ -134,7 +134,7 @@ Abrir menu Pesquisar Rascunhos - Toots agendados + Toots Agendados Privacidade do toot Aviso de conteúdo Teclado de emojis @@ -325,7 +325,6 @@ Listas Não foi possível renomear a lista Listas - Não foi possível criar a lista Não foi possível apagar a lista Criar uma lista diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 6e6924a58..1a9709441 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -550,4 +550,15 @@ Вхід Не вдалося завантажити сторінку входу. Збереження чернетки… + Відхилити + Подробиці + Увійдіть повторно, щоб отримувати push-сповіщення + Увійдіть повторно до всіх облікових записів, щоб увімкнути підтримку push-сповіщень. + Щоб використовувати push-сповіщення через UnifiedPush, Tusky потребує дозволу стежити за сповіщеннями на вашому сервері Mastodon. Це вимагає повторного входу, щоб змінити області OAuth, надані Tusky. Використання параметра повторного входу тут або в налаштуваннях облікового запису збереже всі ваші локальні чернетки та кеш. + Ви повторно увійшли до свого поточного облікового запису, щоб надати дозвіл на стеження Tusky. Однак у вас все ще є інші облікові записи, які не мігрували таким чином. Перейдіть до них і повторно увійдіть до них по одному, щоб забезпечити підтримку UnifiedPush сповіщень. + Приєднується %1$s + Редагувати зображення + 1+ + Неможливо редагувати зображення. + Не вдалося завантажити подробиці облікового запису \ 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 6788526a4..7eb9ba336 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -517,4 +517,15 @@ Đăng nhập Không thể tải trang đăng nhập. Đang lưu nháp… + Bỏ qua + Đăng nhập lại để hiện thông báo đẩy + Chi tiết + Tham gia vào %1$s + Đăng nhập lại tất cả tài khoản để kích hoạt thông báo đẩy. + Bạn đã đăng nhập lại vào tài khoản hiện tại của mình để cấp quyền thông báo đẩy cho Tusky. Tuy nhiên, bạn vẫn có các tài khoản khác chưa kích hoạt thông báo đẩy theo cách này. Chuyển sang chúng và đăng nhập từng cái một để cho phép hỗ trợ thông báo UnifiedPush. + Để sử dụng thông báo đẩy qua UnifiedPush, Tusky cần có quyền đăng ký thông báo trên máy chủ Mastodon của bạn. Bạn hãy thoát ra rồi đăng nhập lại để thay đổi phạm vi OAuth được cấp cho Tusky. Sử dụng đăng nhập lại ở đây hoặc trong cài đặt Tài khoản sẽ bảo toàn tất cả các tút nháp và bộ nhớ đệm trên điện thoại của bạn. + 1+ + Sửa ảnh + Hình ảnh này không thể sửa. + Không thể tải thông tin tài khoản \ 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 ed74d003e..38c0a30d1 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -31,7 +31,7 @@ 已置顶 正在关注 关注者 - 收藏 + 喜欢 被隐藏的用户 被屏蔽的用户 关注请求 @@ -50,7 +50,7 @@ 还没有内容。 还没有内容,向下拉动即可刷新! %s 转嘟了你的嘟文 - %s 收藏了你的嘟文 + %s 喜欢了你的嘟文 %s 关注了你 举报 @%s 是否有更多信息需报告? @@ -58,8 +58,8 @@ 回复 转嘟 取消转嘟 - 收藏 - 取消收藏 + 喜欢 + 取消喜欢 更多 发表嘟文 登录 Mastodon 帐号 @@ -81,7 +81,7 @@ 个人资料 设置 帐户设置 - 收藏 + 喜欢 被隐藏的用户 被屏蔽的用户 关注请求 @@ -112,7 +112,7 @@ 话题 打开转嘟用户主页 显示转嘟 - 显示收藏 + 显示喜欢 话题 提及 链接 @@ -171,7 +171,7 @@ 被提及 有新的关注者 嘟文被转嘟 - 嘟文被收藏 + 嘟文被喜欢 投票已结束 外观 应用主题 @@ -215,8 +215,8 @@ 当有用户关注我时 转嘟 当我的嘟文被转发时通知 - 收藏 - 当有用户收藏了我的嘟文时通知 + 喜欢 + 当有用户喜欢了我的嘟文时 投票 当我参与的投票结束时 %s 提及了你 @@ -337,13 +337,13 @@ 取消置顶 置顶 - <b>%1$s</b> 次收藏 + <b>%1$s</b> 次喜欢 <b>%s</b> 次转嘟 转嘟 - 收藏 + 喜欢 %1$s %1$s 和 %2$s %1$s,%2$s 和 %3$d 等人 @@ -354,7 +354,7 @@ 内容警告:%s 没有描述信息 被转嘟 - 被收藏 + 被喜欢 公开 @@ -495,8 +495,8 @@ 限制时间线通知 一些可能影响您精神状态的信息将被隐藏,这些信息包括: \n -\n - 收藏、转发、关注通知 -\n - 收藏、转发数 +\n - 喜欢、转发、关注通知 +\n - 喜欢、转发数 \n - 账号的已关注数量、嘟文数量 \n \n 推送通知不会被影响,但可以在通知设置中手动禁用。 @@ -516,7 +516,7 @@ 即使您的账号未上锁,管理员 %1$s 认为您可能需要手动处理来自这些账号的关注请求。 删除此对话吗? 删除对话 - 收藏前显示确认对话框 + 喜欢前显示确认对话框 删除书签 30 天 60 天 @@ -536,4 +536,15 @@ 当你进行过互动的嘟文被编辑时发出通知 无法加载登录页。 正在保存草稿… + 重新登陆以启用通知推送 + 不理会 + 详情 + 你已重新登录当前账户,向 Tusky 授予推送订阅权限。但是,你仍然有其他没有以这种方式迁移的账户。切换到它们,逐个重新登录,以启用 UnifiedPush 通知支持。 + 加入于%1$s + 重新登录所有账户来启用推送通知支持。 + 为了通过 UnifiedPush 使用推送通知,Tusky 需要订阅你 Mastodon 服务器通知的权限。这需要重新登录来更改授予 Tusky 的 OAuth 作用域。使用此处或账户首选项中“重新登录”选项将保留你所有的本地草稿和缓存。 + 1+ + 编辑图片 + 无法编辑图片。 + 加载账户详情失败 diff --git a/app/src/main/res/values-zh-rSG/strings.xml b/app/src/main/res/values-zh-rSG/strings.xml index b9fee4f85..9ffa0c971 100644 --- a/app/src/main/res/values-zh-rSG/strings.xml +++ b/app/src/main/res/values-zh-rSG/strings.xml @@ -31,7 +31,7 @@ 已置顶 正在关注 关注者 - 收藏 + 喜欢 被隐藏的用户 被屏蔽的用户 关注请求 @@ -50,7 +50,7 @@ 还没有内容 还没有内容,向下拉动即可刷新 %s 转嘟了你的嘟文 - %s 收藏了你的嘟文 + %s 喜欢了你的嘟文 %s 关注了你 报告用户 @%s 的滥用行为 报告更多信息 @@ -58,8 +58,8 @@ 回复 转嘟 取消转嘟 - 收藏 - 取消收藏 + 喜欢 + 取消喜欢 更多 新嘟文 登录 Mastodon 帐号 @@ -81,7 +81,7 @@ 个人资料 设置 帐户设置 - 收藏 + 喜欢 被隐藏的用户 被屏蔽的用户 关注请求 @@ -112,7 +112,7 @@ 话题 打开转嘟用户主页 显示转嘟 - 显示收藏 + 显示喜欢 话题 提及 链接 @@ -168,7 +168,7 @@ 被提及 有新的关注者 嘟文被转嘟 - 嘟文被收藏 + 嘟文被喜欢 投票已结束 外观 应用主题 @@ -212,8 +212,8 @@ 当有用户关注我时 转嘟 当有用户转嘟了我的嘟文时 - 收藏 - 当有用户收藏了我的嘟文时 + 喜欢 + 当有用户喜欢了我的嘟文时 投票 当我参与的投票结束时 %s 提及了你 @@ -333,13 +333,13 @@ 取消置顶 置顶 - <b>%1$s</b> 次收藏 + %1$s 次喜欢 <b>%s</b> 次转嘟 转嘟 - 收藏 + 喜欢 %1$s %1$s 和 %2$s %1$s, %2$s 和 %3$d 等人 @@ -358,9 +358,7 @@ 被转嘟 - - 被收藏 - + 被喜欢 公开 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 573e600a0..1a644f5de 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -50,7 +50,7 @@ 沒有內容。 還沒有內容,向下拉動即可重新整理! %s 轉嘟了你的嘟文 - %s 收藏了你的嘟文 + %s 最愛了你的嘟文 %s 關注了你 檢舉使用者 @%s 的濫用行為 更多評論? @@ -58,8 +58,8 @@ 回覆 轉嘟 取消轉嘟 - 收藏 - 取消收藏 + 最愛 + 取消最愛 更多 撰寫嘟文 登入 Mastodon 帳號 @@ -81,7 +81,7 @@ 個人資料 設定 帳戶設定 - 我的收藏 + 我的最愛 被靜音的使用者 被封鎖的使用者 關注請求 @@ -171,7 +171,7 @@ 被提及 有新的關注者 嘟文被轉嘟 - 嘟文被加入收藏 + 嘟文被加入最愛 投票已結束 外觀 佈景主題 @@ -215,8 +215,8 @@ 當有使用者關注我時 轉嘟 當有使用者轉嘟了我的嘟文時 - 收藏 - 當有使用者把我的嘟文加入收藏時 + 最愛 + 當有使用者把我的嘟文加入最愛時 投票 當我參與的投票結束時 %s 提及了你 @@ -335,13 +335,13 @@ 取消置頂 置頂 - <b>%1$s</b> 次收藏 + %1$s 次最愛 <b>%s</b> 次轉嘟 轉嘟 - 收藏由 + 最愛由 %1$s %1$s 和 %2$s %1$s, %2$s 和 %3$d 等人 @@ -360,9 +360,7 @@ 被轉嘟 - - 被收藏 - + 被最愛 公開 @@ -444,8 +442,8 @@ 檢查通知設定 有些資訊可能會影響你的心理健康將會被隱藏。包括: \n -\n- 收藏/轉嘟/關注 通知 -\n- 收藏/轉嘟 數量 +\n- 最愛/轉嘟/關注 通知 +\n- 最愛/轉嘟 數量 \n- 關注/貼文 在個人頁面的狀態 \n \n推播通知不會受到影響,但你可以手動檢查你的通知設定。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2a91779ea..ad26b7896 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,11 +9,13 @@ An unidentified authorization error occurred. Authorization was denied. Failed getting a login token. + Failed loading account details 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. Audio files must be less than 40MB. + The image could not be edited. That type of file cannot be uploaded. That file could not be opened. Permission to read media is required. @@ -378,6 +380,7 @@ Video Audio Attachments + 1+ Follow requested @@ -434,6 +437,7 @@ Describe for visually impaired\n(%d character limit) Set caption + Edit image Remove Lock account Requires you to manually approve followers diff --git a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt index 8f97f3c42..74796f377 100644 --- a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt @@ -74,6 +74,7 @@ class BottomSheetActivityTest { emojis = emptyList(), reblogsCount = 0, favouritesCount = 0, + repliesCount = 0, reblogged = false, favourited = false, bookmarked = false, diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt index 95d1bae0e..ef863560d 100644 --- a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt @@ -19,6 +19,7 @@ import android.content.Intent import android.os.Looper.getMainLooper import android.widget.EditText import androidx.test.ext.junit.runners.AndroidJUnit4 +import at.connyduck.calladapter.networkresult.NetworkResult import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository @@ -67,6 +68,8 @@ class ComposeActivityTest { id = 1, domain = instanceDomain, accessToken = "token", + clientId = "id", + clientSecret = "secret", isActive = true, accountId = "1", username = "username", @@ -95,12 +98,12 @@ class ComposeActivityTest { } apiMock = mock { - onBlocking { getCustomEmojis() } doReturn Result.success(emptyList()) + onBlocking { getCustomEmojis() } doReturn NetworkResult.success(emptyList()) onBlocking { getInstance() } doReturn instanceResponseCallback?.invoke().let { instance -> if (instance == null) { - Result.failure(Throwable()) + NetworkResult.failure(Throwable()) } else { - Result.success(instance) + NetworkResult.success(instance) } } } diff --git a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt index d3455ec27..fc5ee636b 100644 --- a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt @@ -166,6 +166,7 @@ class FilterTest { emojis = emptyList(), reblogsCount = 0, favouritesCount = 0, + repliesCount = 0, reblogged = false, favourited = false, bookmarked = false, 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 2778f8c26..c117cf59e 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 @@ -46,6 +46,8 @@ class CachedTimelineRemoteMediatorTest { id = 1, domain = "mastodon.example", accessToken = "token", + clientId = "id", + clientSecret = "secret", isActive = true ) } 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 eabf744c2..808540dec 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 @@ -38,6 +38,8 @@ class NetworkTimelineRemoteMediatorTest { id = 1, domain = "mastodon.example", accessToken = "token", + clientId = "id", + clientSecret = "secret", isActive = true ) } 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 d4ca1f4de..b522e63c0 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 @@ -29,6 +29,7 @@ fun mockStatus(id: String = "100") = Status( emojis = emptyList(), reblogsCount = 1, favouritesCount = 2, + repliesCount = 3, reblogged = false, favourited = true, bookmarked = 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 6463b9e81..e12d06667 100644 --- a/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt @@ -443,6 +443,7 @@ class TimelineDaoTest { emojis = "emojis$statusId", reblogsCount = 1 * statusId.toInt(), favouritesCount = 2 * statusId.toInt(), + repliesCount = 3 * statusId.toInt(), reblogged = even, favourited = !even, bookmarked = false, diff --git a/app/src/test/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptorTest.kt b/app/src/test/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptorTest.kt new file mode 100644 index 000000000..aa070489d --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptorTest.kt @@ -0,0 +1,143 @@ +package com.keylesspalace.tusky.network + +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock + +class InstanceSwitchAuthInterceptorTest { + + private val mockWebServer = MockWebServer() + + @Before + fun setup() { + mockWebServer.start() + } + + @After + fun teardown() { + mockWebServer.shutdown() + } + + @Test + fun `should make regular request when requested`() { + + mockWebServer.enqueue(MockResponse()) + + val accountManager: AccountManager = mock { + on { activeAccount } doAnswer { null } + } + + val okHttpClient = OkHttpClient.Builder() + .addInterceptor(InstanceSwitchAuthInterceptor(accountManager)) + .build() + + val request = Request.Builder() + .get() + .url(mockWebServer.url("/test")) + .build() + + val response = okHttpClient.newCall(request).execute() + + assertEquals(200, response.code) + } + + @Test + fun `should make request to instance requested in special header`() { + mockWebServer.enqueue(MockResponse()) + + val accountManager: AccountManager = mock { + on { activeAccount } doAnswer { + AccountEntity( + id = 1, + domain = "test.domain", + accessToken = "fakeToken", + clientId = "fakeId", + clientSecret = "fakeSecret", + isActive = true + ) + } + } + + val okHttpClient = OkHttpClient.Builder() + .addInterceptor(InstanceSwitchAuthInterceptor(accountManager)) + .build() + + val request = Request.Builder() + .get() + .url("http://" + MastodonApi.PLACEHOLDER_DOMAIN + ":" + mockWebServer.port + "/test") + .header(MastodonApi.DOMAIN_HEADER, mockWebServer.hostName) + .build() + + val response = okHttpClient.newCall(request).execute() + + assertEquals(200, response.code) + + assertNull(mockWebServer.takeRequest().getHeader("Authorization")) + } + + @Test + fun `should make request to current instance when requested and user is logged in`() { + mockWebServer.enqueue(MockResponse()) + + val accountManager: AccountManager = mock { + on { activeAccount } doAnswer { + AccountEntity( + id = 1, + domain = mockWebServer.hostName, + accessToken = "fakeToken", + clientId = "fakeId", + clientSecret = "fakeSecret", + isActive = true + ) + } + } + + val okHttpClient = OkHttpClient.Builder() + .addInterceptor(InstanceSwitchAuthInterceptor(accountManager)) + .build() + + val request = Request.Builder() + .get() + .url("http://" + MastodonApi.PLACEHOLDER_DOMAIN + ":" + mockWebServer.port + "/test") + .build() + + val response = okHttpClient.newCall(request).execute() + + assertEquals(200, response.code) + + assertEquals("Bearer fakeToken", mockWebServer.takeRequest().getHeader("Authorization")) + } + + @Test + fun `should fail to make request when request to current instance is requested but no user is logged in`() { + mockWebServer.enqueue(MockResponse()) + + val accountManager: AccountManager = mock { + on { activeAccount } doAnswer { null } + } + + val okHttpClient = OkHttpClient.Builder() + .addInterceptor(InstanceSwitchAuthInterceptor(accountManager)) + .build() + + val request = Request.Builder() + .get() + .url("http://" + MastodonApi.PLACEHOLDER_DOMAIN + "/test") + .build() + + val response = okHttpClient.newCall(request).execute() + + assertEquals(400, response.code) + assertEquals(0, mockWebServer.requestCount) + } +} diff --git a/fastlane/metadata/android/de/changelogs/94.txt b/fastlane/metadata/android/de/changelogs/94.txt new file mode 100644 index 000000000..711131ca3 --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/94.txt @@ -0,0 +1,9 @@ +Tusky 19.0 + +- Push-Benachrichtigungen via Unified Push. Um Unified Push zu verwenden musst du dich neu einloggen. +- Die Anzahl an Antworten unter einem Beitrag wird jetzt in der Timeline angezeigt. +- Bilder können jetzt vor dem Veröffentlichen zugeschnitten werden. +- Das Erstellungsdatum eines Profils wird jetzt angezeigt. +- Beim Betrachten einer Liste ist jetzt der Listenname ersichtlich. +- Fehlerbehebungen +- verbesserte Übersetzungen diff --git a/fastlane/metadata/android/en-US/changelogs/94.txt b/fastlane/metadata/android/en-US/changelogs/94.txt new file mode 100644 index 000000000..8dc15d853 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/94.txt @@ -0,0 +1,9 @@ +Tusky 19.0 + +- Support for Unified Push. To activate the support you will have to relogin into your accounts. +- The number of responses to a post is now indicated in timelines. +- Images can now by cropped while composing a post. +- Profiles now show the date when they were created. +- When viewing a list the title is now displayed in the toolbar. +- A lot of bugfixes +- Translation improvements \ No newline at end of file diff --git a/fastlane/metadata/android/fa/changelogs/91.txt b/fastlane/metadata/android/fa/changelogs/91.txt new file mode 100644 index 000000000..71c5a0aca --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/91.txt @@ -0,0 +1,6 @@ +تاسکی نگارش ۱۸٫۰ + +- پشتیبانی از گونه‌های آگاهی جدید ماستودون ۳٫۵ +- نشان بات اکنون ظاهر بهتری داشته و با زمینهٔ گزیده تنظیم می‌شود +- متن‌ها اکنون می‌توانند در نمای جزییات فرسته، گزیده شوند +- رفع کلّی مشکل، از جمله مشکلی که جلوی ورود روی اندروید ۶ و پایین‌تر را می‌گرفت diff --git a/fastlane/metadata/android/fr/changelogs/91.txt b/fastlane/metadata/android/fr/changelogs/91.txt new file mode 100644 index 000000000..e385800dc --- /dev/null +++ b/fastlane/metadata/android/fr/changelogs/91.txt @@ -0,0 +1,6 @@ +Tusky v18.0 + +- Les nouveaux types de notifications de Mastodon 3.5 sont maintenant supportés +- Le badge robot est maintenant plus joli et s'adapte au thème choisi +- Il est maintenant possible de sélectionner le texte dans l'écran de détails d'un post +- Beaucoup de bogues résolus, dont un qui empêchait de se connecter sous Android 6 ou inférieur diff --git a/fastlane/metadata/android/fr/changelogs/94.txt b/fastlane/metadata/android/fr/changelogs/94.txt new file mode 100644 index 000000000..15cbac96a --- /dev/null +++ b/fastlane/metadata/android/fr/changelogs/94.txt @@ -0,0 +1,9 @@ +Tusky 19.0 + +- Les notifications via UnifiedPush sont à présent supportées. Pour les activer vous devrez reconnecter vos comptes. +- Le nombre de réponses est maintenant affiché sur chaque post dans les fils. +- Les images peuvent maintenant être rognées lors de l'écriture d'un message. +- Les profils affichent à présent leur date de création. +- Lorsqu'une liste est affichée, son nom apparaît maintenant dans la barre d'outils. +- Beaucoup de bogues résolus. +- Des améliorations sur les traductions. diff --git a/fastlane/metadata/android/gl/changelogs/91.txt b/fastlane/metadata/android/gl/changelogs/91.txt new file mode 100644 index 000000000..00017693d --- /dev/null +++ b/fastlane/metadata/android/gl/changelogs/91.txt @@ -0,0 +1,6 @@ +Tusky v18.0 + +- Soporte para o novos tipos de notificación de Mastodon 3.5 +- A insignia de bot foi redeseñada e combina mellor co decorado seleccionado +- Podes seleccionar texto na vista de detalles da publicación +- Moitos arranxos adicionais, incluíndo o que non permitía acceder en Android <6 diff --git a/fastlane/metadata/android/gl/changelogs/94.txt b/fastlane/metadata/android/gl/changelogs/94.txt new file mode 100644 index 000000000..0e4befb4d --- /dev/null +++ b/fastlane/metadata/android/gl/changelogs/94.txt @@ -0,0 +1,9 @@ +Tusky 19.0 + +- Soporte para Unified Push. Para activar a función tes que volver a acceder ás túas contas. +- Agora aparece nas cronoloxías o número de respostas a unha publicación. +- Podes recortar as imaxes cando escribes unha publicación. +- Os perfís mostran a data na que foron creados. +- Móstrase o título da lista na barra de ferramentas ao visualizala. +- Arranxamos moitos fallos. +- Melloras nas traducións. diff --git a/fastlane/metadata/android/hu/changelogs/91.txt b/fastlane/metadata/android/hu/changelogs/91.txt new file mode 100644 index 000000000..c9ad649e1 --- /dev/null +++ b/fastlane/metadata/android/hu/changelogs/91.txt @@ -0,0 +1,6 @@ +Tusky v18.0 + +- Támogatás az új Mastodon 3.5 értesítési típusokhoz +- A bot jelvény jobban néz ki és alkalmazkodik a választott témához +- A szöveget már kiválaszthatod a bejegyzési részletek megtekintésénél is +- Sok hibajavítás, beleértve egy olyat, mely megakadályozta a bejelentkezést Android 6-on vagy alatta diff --git a/fastlane/metadata/android/hu/changelogs/94.txt b/fastlane/metadata/android/hu/changelogs/94.txt new file mode 100644 index 000000000..fe40fdf1f --- /dev/null +++ b/fastlane/metadata/android/hu/changelogs/94.txt @@ -0,0 +1,9 @@ +Tusky 19.0 + +- Egységes leküldés (Unified Push) támogatása. A támogatás aktiválásához újra jelentkezz be a fiókjaidba. +- A bejegyzésekre érkezett válaszok száma már látható az idővonalon. +- Bejegyzés szerkesztése közben meg lehet vágni a képeket. +- A profilokon látható ezek létrehozásának időpontja. +- Lista megtekintésekor ennek címe látható az eszköztáron. +- Rengeteg hibajavítás +- Fordítási javítások diff --git a/fastlane/metadata/android/nb-NO/changelogs/91.txt b/fastlane/metadata/android/nb-NO/changelogs/91.txt new file mode 100644 index 000000000..fe3c8e0d2 --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/91.txt @@ -0,0 +1,6 @@ +Tusky v18.0 + +- Støtte for Mastodon 3.5-varslingstyper +- Bot-symbolet ser nå bedre ut og endrer seg basert på valgt tema +- Det er nå mulig å markere tekst i skjermbildet som viser innleggsdetaljer +- Fikset flere feil, inkludert en som hindret innlogging på Android 6 og eldre versjoner diff --git a/fastlane/metadata/android/nb-NO/changelogs/94.txt b/fastlane/metadata/android/nb-NO/changelogs/94.txt new file mode 100644 index 000000000..954a80185 --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/94.txt @@ -0,0 +1,9 @@ +Tusky 19.0 + +- Støtte for Unified Push. For å aktivisere dette må du logg inne på kontoene dine på nytt. +- Antall tilbakemeldinger på et innlegg vises nå i tidslinjene. +- Bilder kan nå beskjæres når innlegget opprettes. +- Dato nå en profil ble opprettes vises. +- Visning av liste viser nå navnet på listen i verktøylinjen. +- En mengde feilfikser. +- Oppdaterte oversettelser. diff --git a/fastlane/metadata/android/uk/changelogs/94.txt b/fastlane/metadata/android/uk/changelogs/94.txt new file mode 100644 index 000000000..a31309fdc --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/94.txt @@ -0,0 +1,9 @@ +Tusky 19.0 + +- Підтримка Unified Push. Щоб активувати підтримку, вам потрібно повторно увійти в обліковий запис. +- Кількість відповідей на допис тепер вказана у стрічках. +- Зображення тепер можуть обрізатися під час складання допису. +- Профілі тепер показують дату їхнього створення. +- Під час перегляду списку назва відтепер показана на панелі інструментів. +- Усунення помилок +- Покращення перекладу diff --git a/fastlane/metadata/android/vi/changelogs/94.txt b/fastlane/metadata/android/vi/changelogs/94.txt new file mode 100644 index 000000000..b3f8aa270 --- /dev/null +++ b/fastlane/metadata/android/vi/changelogs/94.txt @@ -0,0 +1,9 @@ +Tusky 19.0 + +- Hỗ trợ Unified Push. Bạn cần đăng nhập lại để sử dụng được. +- Hiện số lượng trả lời trên nút +- Cắt ảnh khi viết tút +- Hiện ngày tham gia Mastodon +- Khi xem danh sách, tựa đề sẽ hiện trên toolbar +- Sửa lỗi vặt +- Cải thiện bản dịch