diff --git a/app/build.gradle b/app/build.gradle index 19e094d5..3413ae94 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -24,8 +24,8 @@ android { applicationId APP_ID minSdkVersion 21 targetSdkVersion 31 - versionCode 87 - versionName "18.0-CW1" + versionCode 94 + versionName "19.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true @@ -99,10 +99,10 @@ ext.roomVersion = '2.4.2' ext.retrofitVersion = '2.9.0' ext.okhttpVersion = '4.9.3' ext.glideVersion = '4.13.1' -ext.daggerVersion = '2.41' +ext.daggerVersion = '2.42' ext.materialdrawerVersion = '8.4.5' ext.emoji2_version = '1.1.0' -ext.filemojicompat_version = '3.2.1' +ext.filemojicompat_version = '3.2.2' // if libraries are changed here, they should also be changed in LicenseActivity dependencies { @@ -135,14 +135,14 @@ dependencies { kapt "androidx.room:room-compiler:$roomVersion" implementation 'androidx.core:core-splashscreen:1.0.0-beta02' - implementation "com.google.android.material:material:1.5.0" + implementation "com.google.android.material:material:1.6.0" implementation "com.google.code.gson:gson:2.9.0" 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" @@ -153,7 +153,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" @@ -176,17 +176,22 @@ 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" implementation "de.c1710:filemojicompat-defaults:$filemojicompat_version" + implementation "org.bouncycastle:bcprov-jdk15on:1.70" + implementation "com.github.UnifiedPush:android-connector:2.0.0" + testImplementation "androidx.test.ext:junit:1.1.3" testImplementation "org.robolectric:robolectric:4.4" testImplementation "org.mockito:mockito-inline:4.4.0" 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/proguard-rules.pro b/app/proguard-rules.pro index b7de4270..7f0c4325 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -82,6 +82,10 @@ -keepattributes SourceFile,LineNumberTable -renamesourcefileattribute SourceFile +# Bouncy Castle -- Keep EC +-keep class org.bouncycastle.jcajce.provider.asymmetric.EC$* { *; } +-keep class org.bouncycastle.jcajce.provider.asymmetric.ec.KeyPairGeneratorSpi$EC + # remove all logging from production apk -assumenosideeffects class android.util.Log { public static *** getStackTraceString(...); diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/36.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/36.json new file mode 100644 index 00000000..d009a9e3 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/36.json @@ -0,0 +1,857 @@ +{ + "formatVersion": 1, + "database": { + "version": 36, + "identityHash": "1b7461c291f67fe0b21f77b95de6a6be", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1b7461c291f67fe0b21f77b95de6a6be')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/37.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/37.json new file mode 100644 index 00000000..8d748248 --- /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 00000000..391d6b86 --- /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 00000000..be96b28a --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/39.json @@ -0,0 +1,887 @@ +{ + "formatVersion": 1, + "database": { + "version": 39, + "identityHash": "ed3b752a3faec9d092d5ac0a2823d5d5", + "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, 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, 'ed3b752a3faec9d092d5ac0a2823d5d5')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 391a8765..55ffd493 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,6 +14,7 @@ + + + + + + + + + + + + + + + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.state.collect { state -> adapter.submitList(state.accounts.asRightOrNull() ?: listOf()) when (state.accounts) { @@ -111,6 +108,7 @@ class AccountsInListFragment : DialogFragment(), Injectable { setupSearchView(state) } + } binding.searchView.isSubmitButtonEnabled = true binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { diff --git a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt index f2f7b38e..5850e321 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt @@ -31,14 +31,13 @@ import android.widget.TextView import androidx.activity.viewModels import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import at.connyduck.sparkbutton.helpers.Utils -import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from -import autodispose2.autoDispose import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.databinding.ActivityListsBinding import com.keylesspalace.tusky.di.Injectable @@ -63,7 +62,7 @@ import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import kotlinx.coroutines.launch import javax.inject.Inject /** @@ -102,19 +101,18 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { DividerItemDecoration(this, DividerItemDecoration.VERTICAL) ) - viewModel.state - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe(this::update) + lifecycleScope.launch { + viewModel.state.collect(this@ListsActivity::update) + } + viewModel.retryLoading() binding.addListButton.setOnClickListener { showlistNameDialog(null) } - viewModel.events.observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe { event -> + lifecycleScope.launch { + viewModel.events.collect { event -> @Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA") when (event) { Event.CREATE_ERROR -> showMessage(R.string.error_create_list) @@ -122,6 +120,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { Event.DELETE_ERROR -> showMessage(R.string.error_delete_list) } } + } } private fun showlistNameDialog(list: MastoList?) { @@ -198,9 +197,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { ).show() } - private fun onListSelected(listId: String) { + private fun onListSelected(listId: String, listTitle: String) { startActivityWithSlideInAnimation( - StatusListActivity.newListIntent(this, listId) + StatusListActivity.newListIntent(this, listId, listTitle) ) } @@ -270,7 +269,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { override fun onClick(v: View) { if (v == itemView) { - onListSelected(getItem(bindingAdapterPosition).id) + val list = getItem(bindingAdapterPosition) + onListSelected(list.id, list.title) } else { onMore(getItem(bindingAdapterPosition), v) } diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 25b70240..24e0c402 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -40,6 +40,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 @@ -60,11 +61,12 @@ 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.enablePushNotificationsWithFallback +import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity import com.keylesspalace.tusky.components.search.SearchActivity @@ -76,11 +78,12 @@ import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ReselectableFragment 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 @@ -130,10 +133,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje lateinit var cacheUpdater: CacheUpdater @Inject - lateinit var conversationRepository: ConversationsRepository - - @Inject - lateinit var draftHelper: DraftHelper + lateinit var logoutUsecase: LogoutUsecase private val binding by viewBinding(ActivityMainBinding::inflate) @@ -242,12 +242,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje setupTabs(showNotificationTab) - // Setup push notifications - if (NotificationHelper.areNotificationsEnabled(this, accountManager)) { - NotificationHelper.enablePullNotifications(this) - } else { - NotificationHelper.disablePullNotifications(this) - } eventHub.events .observeOn(AndroidSchedulers.mainThread()) .autoDispose(this, Lifecycle.Event.ON_DESTROY) @@ -636,7 +630,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } // open LoginActivity to add new account if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) { - startActivityWithSlideInAnimation(LoginActivity.getIntent(this, true)) + startActivityWithSlideInAnimation(LoginActivity.getIntent(this, LoginActivity.MODE_ADDITIONAL_LOGIN)) return false } // change Account @@ -665,24 +659,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 { - 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, false) - } 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() @@ -714,6 +702,16 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje accountManager.updateActiveAccount(me) NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this) + // Setup push notifications + showMigrationNoticeIfNecessary(this, binding.mainCoordinatorLayout, binding.composeButton, accountManager) + if (NotificationHelper.areNotificationsEnabled(this, accountManager)) { + lifecycleScope.launch { + enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager) + } + } else { + disableAllNotifications(this, accountManager) + } + accountLocked = me.locked updateProfiles() diff --git a/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt index 62f95162..638f0e5b 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 javax.inject.Inject @@ -34,16 +33,12 @@ class SplashActivity : AppCompatActivity(), Injectable { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - /** 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 { - LoginActivity.getIntent(this, false) + LoginActivity.getIntent(this, LoginActivity.MODE_DEFAULT) } startActivity(intent) finish() diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index 82604022..5ae1591c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -46,7 +46,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { Kind.FAVOURITES -> getString(R.string.title_favourites) Kind.BOOKMARKS -> getString(R.string.title_bookmarks) Kind.TAG -> getString(R.string.title_tag).format(hashtag) - else -> getString(R.string.title_list_timeline) + else -> intent.getStringExtra(EXTRA_LIST_TITLE) } supportActionBar?.run { @@ -73,6 +73,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { private const val EXTRA_KIND = "kind" private const val EXTRA_LIST_ID = "id" + private const val EXTRA_LIST_TITLE = "title" private const val EXTRA_HASHTAG = "tag" fun newFavouritesIntent(context: Context) = @@ -85,10 +86,11 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { putExtra(EXTRA_KIND, Kind.BOOKMARKS.name) } - fun newListIntent(context: Context, listId: String) = + fun newListIntent(context: Context, listId: String, listTitle: String) = Intent(context, StatusListActivity::class.java).apply { putExtra(EXTRA_KIND, Kind.LIST.name) putExtra(EXTRA_LIST_ID, listId) + putExtra(EXTRA_LIST_TITLE, listTitle) } @JvmStatic diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index 720664bd..76418e01 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -25,11 +25,13 @@ import androidx.appcompat.widget.AppCompatEditText import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration 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 @@ -46,9 +48,9 @@ import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.onTextChanged import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.launch import java.util.regex.Pattern import javax.inject.Inject @@ -253,10 +255,8 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene private fun showSelectListDialog() { val adapter = ListSelectionAdapter(this) - mastodonApi.getLists() - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe( + lifecycleScope.launch { + mastodonApi.getLists().fold( { lists -> adapter.addAll(lists) }, @@ -264,6 +264,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene Log.e("TabPreferenceActivity", "failed to load lists", throwable) } ) + } AlertDialog.Builder(this) .setTitle(R.string.select_list_title) 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 4f58b1ff..0b0115c2 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 cf755990..00000000 --- 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 2a5b3f2c..980f644b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -71,10 +71,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 SparkButton bookmarkButton; @@ -123,6 +123,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); bookmarkButton = itemView.findViewById(R.id.status_bookmark); @@ -360,6 +361,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); } @@ -733,6 +741,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setUsername(status.getUsername()); setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions); setIsReply(actionable.getInReplyToId() != null); + setReplyCount(actionable.getRepliesCount()); setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(), actionable.getAccount().getBot(), statusDisplayOptions); setReblogged(actionable.getReblogged()); @@ -1037,6 +1046,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 bf2c05e0..ae0b0678 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -103,20 +103,26 @@ 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()); - setStatusVisibility(status.getActionable().getVisibility()); + setStatusVisibility(actionable.getVisibility()); } } 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 12cb4a69..66ae898b 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/account/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt index 8f53645d..80de7874 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt @@ -84,6 +84,9 @@ import com.keylesspalace.tusky.view.showMuteAccountDialog import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import java.text.NumberFormat +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale import javax.inject.Inject import kotlin.math.abs @@ -413,6 +416,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI updateToolbar() updateMovedAccount() updateRemoteAccount() + updateAccountJoinedDate() updateAccountStats() invalidateOptionsMenu() @@ -422,6 +426,20 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI } } + private fun updateAccountJoinedDate() { + loadedAccount?.let { account -> + try { + binding.accountDateJoined.text = resources.getString( + R.string.account_date_joined, + SimpleDateFormat("MMMM, yyyy", Locale.getDefault()).format(account.createdAt) + ) + binding.accountDateJoined.visibility = View.VISIBLE + } catch (e: ParseException) { + binding.accountDateJoined.visibility = View.GONE + } + } + } + /** * Load account's avatar and header image */ diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt index 0934c48f..c7e6781a 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 a243dcef..0ecbb9db 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.Uri @@ -56,6 +57,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 @@ -78,12 +82,12 @@ import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.ComposeTokenizer import com.keylesspalace.tusky.util.PickMediaFiles import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.afterTextChanged 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 @@ -152,6 +156,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) @@ -186,6 +216,7 @@ class ComposeActivity : viewModel.updateDescription(item.localId, newDescription) } }, + onEditImage = this::editImageInQueue, onRemove = this::removeMediaFromQueue ) binding.composeMediaPreviewBar.layoutManager = @@ -307,7 +338,8 @@ class ComposeActivity : ComposeAutoCompleteAdapter( this, preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), - preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true) ) ) binding.composeEditField.setTokenizer(ComposeTokenizer()) @@ -375,8 +407,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 @@ -521,19 +558,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() @@ -867,6 +908,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/ComposeAutoCompleteAdapter.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java deleted file mode 100644 index 8a4f0ce1..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java +++ /dev/null @@ -1,320 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.components.compose; - -import android.content.Context; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.BaseAdapter; -import android.widget.Filter; -import android.widget.Filterable; -import android.widget.ImageView; -import android.widget.TextView; - -import com.bumptech.glide.Glide; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.entity.Emoji; -import com.keylesspalace.tusky.entity.HashTag; -import com.keylesspalace.tusky.entity.TimelineAccount; -import com.keylesspalace.tusky.util.CustomEmojiHelper; -import com.keylesspalace.tusky.util.ImageLoadingHelper; - -import java.util.ArrayList; -import java.util.List; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -/** - * Created by charlag on 12/11/17. - */ - -public class ComposeAutoCompleteAdapter extends BaseAdapter - implements Filterable { - private static final int ACCOUNT_VIEW_TYPE = 1; - private static final int HASHTAG_VIEW_TYPE = 2; - private static final int EMOJI_VIEW_TYPE = 3; - private static final int SEPARATOR_VIEW_TYPE = 0; - - private final ArrayList resultList; - private final AutocompletionProvider autocompletionProvider; - private final boolean animateAvatar; - private final boolean animateEmojis; - - public ComposeAutoCompleteAdapter(AutocompletionProvider autocompletionProvider, boolean animateAvatar, boolean animateEmojis) { - super(); - resultList = new ArrayList<>(); - this.autocompletionProvider = autocompletionProvider; - this.animateAvatar = animateAvatar; - this.animateEmojis = animateEmojis; - } - - @Override - public int getCount() { - return resultList.size(); - } - - @Override - public AutocompleteResult getItem(int index) { - return resultList.get(index); - } - - @Override - public long getItemId(int position) { - return position; - } - - @Override - @NonNull - public Filter getFilter() { - return new Filter() { - @Override - public CharSequence convertResultToString(Object resultValue) { - if (resultValue instanceof AccountResult) { - return formatUsername(((AccountResult) resultValue)); - } else if (resultValue instanceof HashtagResult) { - return formatHashtag((HashtagResult) resultValue); - } else if (resultValue instanceof EmojiResult) { - return formatEmoji((EmojiResult) resultValue); - } else { - return ""; - } - } - - // This method is invoked in a worker thread. - @Override - protected FilterResults performFiltering(CharSequence constraint) { - FilterResults filterResults = new FilterResults(); - if (constraint != null) { - List results = - autocompletionProvider.search(constraint.toString()); - filterResults.values = results; - filterResults.count = results.size(); - } - return filterResults; - } - - @SuppressWarnings("unchecked") - @Override - protected void publishResults(CharSequence constraint, FilterResults results) { - if (results != null && results.count > 0) { - resultList.clear(); - resultList.addAll((List) results.values); - notifyDataSetChanged(); - } else { - notifyDataSetInvalidated(); - } - } - }; - } - - @Override - @NonNull - public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { - View view = convertView; - final Context context = parent.getContext(); - - switch (getItemViewType(position)) { - case ACCOUNT_VIEW_TYPE: - AccountViewHolder accountViewHolder; - if (convertView == null) { - view = ((LayoutInflater) context - .getSystemService(Context.LAYOUT_INFLATER_SERVICE)) - .inflate(R.layout.item_autocomplete_account, parent, false); - } - if (view.getTag() == null) { - view.setTag(new AccountViewHolder(view)); - } - accountViewHolder = (AccountViewHolder) view.getTag(); - - AccountResult accountResult = ((AccountResult) getItem(position)); - if (accountResult != null) { - TimelineAccount account = accountResult.account; - String formattedUsername = context.getString( - R.string.post_username_format, - account.getUsername() - ); - accountViewHolder.username.setText(formattedUsername); - CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), - account.getEmojis(), accountViewHolder.displayName, animateEmojis); - accountViewHolder.displayName.setText(emojifiedName); - - int avatarRadius = accountViewHolder.avatar.getContext().getResources() - .getDimensionPixelSize(R.dimen.avatar_radius_42dp); - - ImageLoadingHelper.loadAvatar( - account.getAvatar(), - accountViewHolder.avatar, - avatarRadius, - animateAvatar - ); - } - break; - - case HASHTAG_VIEW_TYPE: - if (convertView == null) { - view = ((LayoutInflater) context - .getSystemService(Context.LAYOUT_INFLATER_SERVICE)) - .inflate(R.layout.item_autocomplete_hashtag, parent, false); - } - - HashtagResult result = (HashtagResult) getItem(position); - if (result != null) { - ((TextView) view).setText(formatHashtag(result)); - } - break; - - case EMOJI_VIEW_TYPE: - EmojiViewHolder emojiViewHolder; - if (convertView == null) { - view = ((LayoutInflater) context - .getSystemService(Context.LAYOUT_INFLATER_SERVICE)) - .inflate(R.layout.item_autocomplete_emoji, parent, false); - } - if (view.getTag() == null) { - view.setTag(new EmojiViewHolder(view)); - } - emojiViewHolder = (EmojiViewHolder) view.getTag(); - - EmojiResult emojiResult = ((EmojiResult) getItem(position)); - if (emojiResult != null) { - Emoji emoji = emojiResult.emoji; - String formattedShortcode = context.getString( - R.string.emoji_shortcode_format, - emoji.getShortcode() - ); - emojiViewHolder.shortcode.setText(formattedShortcode); - Glide.with(emojiViewHolder.preview) - .load(emoji.getUrl()) - .into(emojiViewHolder.preview); - } - break; - - case SEPARATOR_VIEW_TYPE: - if (convertView == null) { - view = ((LayoutInflater) context - .getSystemService(Context.LAYOUT_INFLATER_SERVICE)) - .inflate(R.layout.item_autocomplete_divider, parent, false); - } - break; - default: - throw new AssertionError("unknown view type"); - } - - return view; - } - - private static String formatUsername(AccountResult result) { - return String.format("@%s", result.account.getUsername()); - } - - private static String formatHashtag(HashtagResult result) { - return String.format("#%s", result.hashtag); - } - - private static String formatEmoji(EmojiResult result) { - return String.format(":%s:", result.emoji.getShortcode()); - } - - @Override - public int getViewTypeCount() { - return 4; - } - - @Override - public int getItemViewType(int position) { - AutocompleteResult item = getItem(position); - - if (item instanceof AccountResult) { - return ACCOUNT_VIEW_TYPE; - } else if (item instanceof HashtagResult) { - return HASHTAG_VIEW_TYPE; - } else if (item instanceof EmojiResult) { - return EMOJI_VIEW_TYPE; - } else { - return SEPARATOR_VIEW_TYPE; - } - } - - @Override - public boolean areAllItemsEnabled() { - // there may be separators - return false; - } - - @Override - public boolean isEnabled(int position) { - return !(getItem(position) instanceof ResultSeparator); - } - - public abstract static class AutocompleteResult { - AutocompleteResult() { - } - } - - public final static class AccountResult extends AutocompleteResult { - private final TimelineAccount account; - - public AccountResult(TimelineAccount account) { - this.account = account; - } - } - - public final static class HashtagResult extends AutocompleteResult { - private final String hashtag; - - public HashtagResult(HashTag hashtag) { - this.hashtag = hashtag.getName(); - } - } - - public final static class EmojiResult extends AutocompleteResult { - private final Emoji emoji; - - public EmojiResult(Emoji emoji) { - this.emoji = emoji; - } - } - - public final static class ResultSeparator extends AutocompleteResult {} - - public interface AutocompletionProvider { - List search(String mention); - } - - private class AccountViewHolder { - final TextView username; - final TextView displayName; - final ImageView avatar; - - private AccountViewHolder(View view) { - username = view.findViewById(R.id.username); - displayName = view.findViewById(R.id.display_name); - avatar = view.findViewById(R.id.avatar); - } - } - - private class EmojiViewHolder { - final TextView shortcode; - final ImageView preview; - - private EmojiViewHolder(View view) { - shortcode = view.findViewById(R.id.shortcode); - preview = view.findViewById(R.id.preview); - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.kt new file mode 100644 index 00000000..e825798c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.kt @@ -0,0 +1,175 @@ +/* Copyright 2022 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.compose + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.Filter +import android.widget.Filterable +import androidx.annotation.WorkerThread +import com.bumptech.glide.Glide +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding +import com.keylesspalace.tusky.databinding.ItemAutocompleteEmojiBinding +import com.keylesspalace.tusky.databinding.ItemAutocompleteHashtagBinding +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.visible + +class ComposeAutoCompleteAdapter( + private val autocompletionProvider: AutocompletionProvider, + private val animateAvatar: Boolean, + private val animateEmojis: Boolean, + private val showBotBadge: Boolean +) : BaseAdapter(), Filterable { + + private var resultList: List = emptyList() + + override fun getCount() = resultList.size + + override fun getItem(index: Int): AutocompleteResult { + return resultList[index] + } + + override fun getItemId(position: Int): Long { + return position.toLong() + } + + override fun getFilter(): Filter { + return object : Filter() { + + override fun convertResultToString(resultValue: Any): CharSequence { + return when (resultValue) { + is AutocompleteResult.AccountResult -> formatUsername(resultValue) + is AutocompleteResult.HashtagResult -> formatHashtag(resultValue) + is AutocompleteResult.EmojiResult -> formatEmoji(resultValue) + else -> "" + } + } + + @WorkerThread + override fun performFiltering(constraint: CharSequence?): FilterResults { + val filterResults = FilterResults() + if (constraint != null) { + val results = autocompletionProvider.search(constraint.toString()) + filterResults.values = results + filterResults.count = results.size + } + return filterResults + } + + override fun publishResults(constraint: CharSequence?, results: FilterResults) { + if (results.count > 0) { + resultList = results.values as List + notifyDataSetChanged() + } else { + notifyDataSetInvalidated() + } + } + } + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val itemViewType = getItemViewType(position) + val context = parent.context + + val view: View = convertView ?: run { + val layoutInflater = LayoutInflater.from(context) + val binding = when (itemViewType) { + ACCOUNT_VIEW_TYPE -> ItemAutocompleteAccountBinding.inflate(layoutInflater) + HASHTAG_VIEW_TYPE -> ItemAutocompleteHashtagBinding.inflate(layoutInflater) + EMOJI_VIEW_TYPE -> ItemAutocompleteEmojiBinding.inflate(layoutInflater) + else -> throw AssertionError("unknown view type") + } + binding.root.tag = binding + binding.root + } + + when (val binding = view.tag) { + is ItemAutocompleteAccountBinding -> { + val accountResult = getItem(position) as AutocompleteResult.AccountResult + val account = accountResult.account + binding.username.text = context.getString(R.string.post_username_format, account.username) + binding.displayName.text = account.name.emojify(account.emojis, binding.displayName, animateEmojis) + val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp) + loadAvatar( + account.avatar, + binding.avatar, + avatarRadius, + animateAvatar + ) + binding.avatarBadge.visible(showBotBadge && account.bot) + } + is ItemAutocompleteHashtagBinding -> { + val result = getItem(position) as AutocompleteResult.HashtagResult + binding.root.text = formatHashtag(result) + } + is ItemAutocompleteEmojiBinding -> { + val emojiResult = getItem(position) as AutocompleteResult.EmojiResult + val (shortcode, url) = emojiResult.emoji + binding.shortcode.text = context.getString(R.string.emoji_shortcode_format, shortcode) + Glide.with(binding.preview) + .load(url) + .into(binding.preview) + } + } + return view + } + + override fun getViewTypeCount() = 3 + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is AutocompleteResult.AccountResult -> ACCOUNT_VIEW_TYPE + is AutocompleteResult.HashtagResult -> HASHTAG_VIEW_TYPE + is AutocompleteResult.EmojiResult -> EMOJI_VIEW_TYPE + } + } + + sealed class AutocompleteResult { + class AccountResult(val account: TimelineAccount) : AutocompleteResult() + + class HashtagResult(val hashtag: String) : AutocompleteResult() + + class EmojiResult(val emoji: Emoji) : AutocompleteResult() + } + + interface AutocompletionProvider { + fun search(token: String): List + } + + companion object { + private const val ACCOUNT_VIEW_TYPE = 0 + private const val HASHTAG_VIEW_TYPE = 1 + private const val EMOJI_VIEW_TYPE = 2 + + private fun formatUsername(result: AutocompleteResult.AccountResult): String { + return String.format("@%s", result.account.username) + } + + private fun formatHashtag(result: AutocompleteResult.HashtagResult): String { + return String.format("#%s", result.hashtag) + } + + private fun formatEmoji(result: AutocompleteResult.EmojiResult): String { + return String.format(":%s:", result.emoji.shortcode) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ComposeTokenizer.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer.kt similarity index 98% rename from app/src/main/java/com/keylesspalace/tusky/util/ComposeTokenizer.kt rename to app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer.kt index 6fee42ed..7b3d208b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ComposeTokenizer.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.util +package com.keylesspalace.tusky.components.compose import android.text.SpannableString import android.text.Spanned diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index 7faf1139..a7e1779c 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,7 +23,9 @@ 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 import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository @@ -51,7 +53,6 @@ import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.rxSingle import kotlinx.coroutines.withContext -import java.util.Locale import javax.inject.Inject class ComposeViewModel @Inject constructor( @@ -94,6 +95,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 + init { viewModelScope.launch { emoji.postValue(instanceInfoRepo.getEmojis()) @@ -121,13 +125,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, @@ -135,8 +142,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) @@ -201,7 +219,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 @@ -330,48 +348,37 @@ class ComposeViewModel @Inject constructor( return true } - fun searchAutocompleteSuggestions(token: String): List { + fun searchAutocompleteSuggestions(token: String): List { when (token[0]) { '@' -> { - return try { - api.searchAccounts(query = token.substring(1), limit = 10) - .blockingGet() - .map { ComposeAutoCompleteAdapter.AccountResult(it) } - } catch (e: Throwable) { - Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e) - emptyList() - } + return api.searchAccountsSync(query = token.substring(1), limit = 10) + .fold({ accounts -> + accounts.map { AutocompleteResult.AccountResult(it) } + }, { e -> + Log.e(TAG, "Autocomplete search for $token failed.", e) + emptyList() + }) } '#' -> { - return try { - api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10) - .blockingGet() - .hashtags - .map { ComposeAutoCompleteAdapter.HashtagResult(it) } - } catch (e: Throwable) { - Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e) - emptyList() - } + return api.searchSync(query = token, type = SearchType.Hashtag.apiParameter, limit = 10) + .fold({ searchResult -> + searchResult.hashtags.map { AutocompleteResult.HashtagResult(it.name) } + }, { e -> + Log.e(TAG, "Autocomplete search for $token failed.", e) + emptyList() + }) } ':' -> { val emojiList = emoji.value ?: return emptyList() + val incomplete = token.substring(1) - val incomplete = token.substring(1).lowercase(Locale.ROOT) - val results = ArrayList() - val resultsInside = ArrayList() - for (emoji in emojiList) { - val shortcode = emoji.shortcode.lowercase(Locale.ROOT) - if (shortcode.startsWith(incomplete)) { - results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji)) - } else if (shortcode.indexOf(incomplete, 1) != -1) { - resultsInside.add(ComposeAutoCompleteAdapter.EmojiResult(emoji)) - } + return emojiList.filter { emoji -> + emoji.shortcode.contains(incomplete, ignoreCase = true) + }.sortedBy { emoji -> + emoji.shortcode.indexOf(incomplete, ignoreCase = true) + }.map { emoji -> + AutocompleteResult.EmojiResult(emoji) } - if (results.isNotEmpty() && resultsInside.isNotEmpty()) { - results.add(ComposeAutoCompleteAdapter.ResultSeparator()) - } - results.addAll(resultsInside) - return results } else -> { Log.w(TAG, "Unexpected autocompletion token: $token") diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt index 0b1fa8c4..be54a1aa 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 f1debc98..324540d1 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,8 +225,16 @@ class MediaUploader @Inject constructor( null } - val result = mediaUploadApi.uploadMedia(body, description).getOrThrow() - send(UploadEvent.FinishedEvent(result.id)) + mediaUploadApi.uploadMedia(body, description).fold({ result -> + send(UploadEvent.FinishedEvent(result.id)) + }, { throwable -> + val errorMessage = throwable.getServerErrorMessage() + if (errorMessage == null) { + throw throwable + } else { + throw UploadServerError(errorMessage) + } + }) awaitClose() } } @@ -240,7 +251,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 0c946514..a5a8ed27 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 f585b4ea..401d6146 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, @@ -132,6 +137,7 @@ data class ConversationStatusEntity( fun TimelineAccount.toEntity() = ConversationAccountEntity( id = id, + localUsername = localUsername, username = username, displayName = name, avatar = avatar, @@ -149,6 +155,7 @@ fun Status.toEntity() = createdAt = createdAt, emojis = emojis, favouritesCount = favouritesCount, + repliesCount = repliesCount, favourited = favourited, bookmarked = bookmarked, sensitive = sensitive, @@ -163,10 +170,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 c7224c4d..7ff4daa7 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 470675d1..fae55f0b 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 ffb88a94..19280441 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(), + 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(), - 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) { @@ -169,4 +185,4 @@ public class ConversationViewHolder extends StatusBaseViewHolder { content.setFilters(NO_INPUT_FILTER); } } -} \ No newline at end of file +} 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 2f1c0366..79950140 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,20 +22,27 @@ 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.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 @@ -44,29 +51,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) @@ -89,56 +98,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() { @@ -201,7 +260,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) { @@ -246,6 +305,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) @@ -256,20 +328,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 26984c8e..02a44f95 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 12c5eb0b..00000000 --- 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 9326a05c..735aa26c 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 8ed26d7b..20c44ba4 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 e52da81c..9cd28bf2 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 @@ -27,6 +27,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 @@ -34,6 +35,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.rickRoll @@ -93,12 +95,17 @@ class LoginActivity : BaseActivity(), Injectable { if (savedInstanceState == null && BuildConfig.CUSTOM_INSTANCE.isNotBlank() && - !isAdditionalLogin() + !isAdditionalLogin() && !isAccountMigration() ) { binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE) binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length) } + if (isAccountMigration()) { + binding.domainEditText.setText(accountManager.activeAccount!!.domain) + binding.domainEditText.isEnabled = false + } + if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) { Glide.with(binding.loginLogo) .load(BuildConfig.CUSTOM_LOGO_URL) @@ -122,7 +129,7 @@ class LoginActivity : BaseActivity(), Injectable { textView?.movementMethod = LinkMovementMethod.getInstance() } - if (isAdditionalLogin()) { + if (isAdditionalLogin() || isAccountMigration()) { setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayShowTitleEnabled(false) @@ -137,7 +144,7 @@ class LoginActivity : BaseActivity(), Injectable { override fun finish() { super.finish() - if (isAdditionalLogin()) { + if (isAdditionalLogin() || isAccountMigration()) { overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right) } } @@ -244,26 +251,50 @@ class LoginActivity : BaseActivity(), Injectable { domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code" ).fold( { accessToken -> - accountManager.addAccount(accessToken.accessToken, domain) - - val intent = Intent(this, MainActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - startActivity(intent) - finish() - overridePendingTransition(R.anim.explode, R.anim.explode) + 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 @@ -276,19 +307,28 @@ class LoginActivity : BaseActivity(), Injectable { } private fun isAdditionalLogin(): Boolean { - return intent.getBooleanExtra(LOGIN_MODE, false) + return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_ADDITIONAL_LOGIN + } + + private fun isAccountMigration(): Boolean { + return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_MIGRATION } companion object { private const val TAG = "LoginActivity" // logging tag - private const val OAUTH_SCOPES = "read write follow" + private const val OAUTH_SCOPES = "read write follow push" private const val LOGIN_MODE = "LOGIN_MODE" private const val DOMAIN = "domain" private const val CLIENT_ID = "clientId" private const val CLIENT_SECRET = "clientSecret" + const val MODE_DEFAULT = 0 + const val MODE_ADDITIONAL_LOGIN = 1 + // "Migration" is used to update the OAuth scope granted to the client + const val MODE_MIGRATION = 2 + @JvmStatic - fun getIntent(context: Context, mode: Boolean): Intent { + fun getIntent(context: Context, mode: Int): Intent { val loginIntent = Intent(context, LoginActivity::class.java) loginIntent.putExtra(LOGIN_MODE, mode) return loginIntent diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt index 2ed38720..07bd5652 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 79586897..45ecd0f6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java @@ -57,6 +57,7 @@ import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Poll; import com.keylesspalace.tusky.entity.PollOption; import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver; import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver; import com.keylesspalace.tusky.util.StringUtils; @@ -457,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) { @@ -539,13 +522,18 @@ public class NotificationHelper { } } - private static boolean filterNotification(AccountEntity account, Notification notification, + public static boolean filterNotification(AccountEntity account, Notification notification, + Context context) { + return filterNotification(account, notification.getType(), context); + } + + public static boolean filterNotification(AccountEntity account, Notification.Type type, Context context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - String channelId = getChannelId(account, notification); + String channelId = getChannelId(account, type); if(channelId == null) { // unknown notificationtype return false; @@ -554,7 +542,7 @@ public class NotificationHelper { return channel.getImportance() > NotificationManager.IMPORTANCE_NONE; } - switch (notification.getType()) { + switch (type) { case MENTION: return account.getNotificationsMentioned(); case STATUS: @@ -580,7 +568,12 @@ public class NotificationHelper { @Nullable private static String getChannelId(AccountEntity account, Notification notification) { - switch (notification.getType()) { + return getChannelId(account, notification.getType()); + } + + @Nullable + private static String getChannelId(AccountEntity account, Notification.Type type) { + switch (type) { case MENTION: return CHANNEL_MENTION + account.getIdentifier(); case STATUS: diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt new file mode 100644 index 00000000..0d804dd9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt @@ -0,0 +1,229 @@ +/* Copyright 2022 Tusky contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +@file:JvmName("PushNotificationHelper") +package com.keylesspalace.tusky.components.notifications + +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import android.util.Log +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.preference.PreferenceManager +import 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 +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.CryptoUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.unifiedpush.android.connector.UnifiedPush +import retrofit2.HttpException + +private const val TAG = "PushNotificationHelper" + +private const val KEY_MIGRATION_NOTICE_DISMISSED = "migration_notice_dismissed" + +private fun anyAccountNeedsMigration(accountManager: AccountManager): Boolean = + accountManager.accounts.any(::accountNeedsMigration) + +private fun accountNeedsMigration(account: AccountEntity): Boolean = + !account.oauthScopes.contains("push") + +fun currentAccountNeedsMigration(accountManager: AccountManager): Boolean = + accountManager.activeAccount?.let(::accountNeedsMigration) ?: false + +fun showMigrationNoticeIfNecessary( + context: Context, + parent: View, + anchorView: View?, + accountManager: AccountManager +) { + // No point showing anything if we cannot enable it + if (!isUnifiedPushAvailable(context)) return + if (!anyAccountNeedsMigration(accountManager)) return + + val pm = PreferenceManager.getDefaultSharedPreferences(context) + if (pm.getBoolean(KEY_MIGRATION_NOTICE_DISMISSED, false)) return + + Snackbar.make(parent, R.string.tips_push_notification_migration, Snackbar.LENGTH_INDEFINITE) + .setAnchorView(anchorView) + .setAction(R.string.action_details) { showMigrationExplanationDialog(context, accountManager) } + .show() +} + +private fun showMigrationExplanationDialog(context: Context, accountManager: AccountManager) { + AlertDialog.Builder(context).apply { + if (currentAccountNeedsMigration(accountManager)) { + setMessage(R.string.dialog_push_notification_migration) + setPositiveButton(R.string.title_migration_relogin) { _, _ -> + context.startActivity(LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION)) + } + } else { + setMessage(R.string.dialog_push_notification_migration_other_accounts) + } + setNegativeButton(R.string.action_dismiss) { dialog, _ -> + val pm = PreferenceManager.getDefaultSharedPreferences(context) + pm.edit().putBoolean(KEY_MIGRATION_NOTICE_DISMISSED, true).apply() + dialog.dismiss() + } + show() + } +} + +private suspend fun enableUnifiedPushNotificationsForAccount(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity) { + if (isUnifiedPushNotificationEnabledForAccount(account)) { + // Already registered, update the subscription to match notification settings + updateUnifiedPushSubscription(context, api, accountManager, account) + } else { + UnifiedPush.registerAppWithDialog(context, account.id.toString(), features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE)) + } +} + +fun disableUnifiedPushNotificationsForAccount(context: Context, account: AccountEntity) { + if (!isUnifiedPushNotificationEnabledForAccount(account)) { + // Not registered + return + } + + UnifiedPush.unregisterApp(context, account.id.toString()) +} + +fun isUnifiedPushNotificationEnabledForAccount(account: AccountEntity): Boolean = + account.unifiedPushUrl.isNotEmpty() + +private fun isUnifiedPushAvailable(context: Context): Boolean = + UnifiedPush.getDistributors(context).isNotEmpty() + +fun canEnablePushNotifications(context: Context, accountManager: AccountManager): Boolean = + isUnifiedPushAvailable(context) && !anyAccountNeedsMigration(accountManager) + +suspend fun enablePushNotificationsWithFallback(context: Context, api: MastodonApi, accountManager: AccountManager) { + if (!canEnablePushNotifications(context, accountManager)) { + // No UP distributors + NotificationHelper.enablePullNotifications(context) + return + } + + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + accountManager.accounts.forEach { + val notificationGroupEnabled = Build.VERSION.SDK_INT < 28 || + nm.getNotificationChannelGroup(it.identifier)?.isBlocked == false + val shouldEnable = it.notificationsEnabled && notificationGroupEnabled + + if (shouldEnable) { + enableUnifiedPushNotificationsForAccount(context, api, accountManager, it) + } else { + disableUnifiedPushNotificationsForAccount(context, it) + } + } +} + +private fun disablePushNotifications(context: Context, accountManager: AccountManager) { + accountManager.accounts.forEach { + disableUnifiedPushNotificationsForAccount(context, it) + } +} + +fun disableAllNotifications(context: Context, accountManager: AccountManager) { + disablePushNotifications(context, accountManager) + NotificationHelper.disablePullNotifications(context) +} + +private fun buildSubscriptionData(context: Context, account: AccountEntity): Map = + buildMap { + Notification.Type.asList.forEach { + put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(account, it, context)) + } + } + +// Called by UnifiedPush callback +suspend fun registerUnifiedPushEndpoint( + context: Context, + api: MastodonApi, + accountManager: AccountManager, + account: AccountEntity, + endpoint: String +) = 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 + // This makes it not directly compatible with UnifiedPush + // As of now, we use it purely as a way to trigger a pull + val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1) + val auth = CryptoUtil.secureRandomBytesEncoded(16) + + 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}") + + account.pushPubKey = keyPair.pubkey + account.pushPrivKey = keyPair.privKey + account.pushAuth = auth + account.pushServerKey = it.serverKey + account.unifiedPushUrl = endpoint + accountManager.saveAccount(account) + } +} + +// Synchronize the enabled / disabled state of notifications with server-side subscription +suspend fun updateUnifiedPushSubscription(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity) { + withContext(Dispatchers.IO) { + api.updatePushNotificationSubscription( + "Bearer ${account.accessToken}", account.domain, + buildSubscriptionData(context, account) + ).onSuccess { + Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}") + + account.pushServerKey = it.serverKey + accountManager.saveAccount(account) + } + } +} + +suspend fun unregisterUnifiedPushEndpoint(api: MastodonApi, accountManager: AccountManager, account: AccountEntity) { + withContext(Dispatchers.IO) { + api.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain) + .onFailure { + Log.d(TAG, "Error unregistering push endpoint for account " + account.id) + Log.d(TAG, Log.getStackTraceString(it)) + Log.d(TAG, (it as HttpException).response().toString()) + } + .onSuccess { + Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id) + // Clear the URL in database + account.unifiedPushUrl = "" + account.pushServerKey = "" + account.pushAuth = "" + account.pushPrivKey = "" + account.pushPubKey = "" + accountManager.saveAccount(account) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index e6bf83fb..ff4380d3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -23,6 +23,7 @@ import androidx.annotation.DrawableRes import androidx.preference.PreferenceFragmentCompat import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.AccountListActivity +import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.FiltersActivity import com.keylesspalace.tusky.R @@ -30,6 +31,8 @@ import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.instancemute.InstanceListActivity +import com.keylesspalace.tusky.components.login.LoginActivity +import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable @@ -139,6 +142,18 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } + if (currentAccountNeedsMigration(accountManager)) { + preference { + setTitle(R.string.title_migration_relogin) + setIcon(R.drawable.ic_logout) + setOnPreferenceClickListener { + val intent = LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION) + (activity as BaseActivity).startActivityWithSlideInAnimation(intent) + true + } + } + } + preferenceCategory(R.string.pref_publishing) { listPreference { setTitle(R.string.pref_default_post_privacy) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusViewModel.kt index 766ed44a..483c8e4d 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 065aa040..af886cdd 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 @@ -27,7 +27,7 @@ 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 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 54183888..bdc77812 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 @@ -103,9 +103,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?) { @@ -226,7 +223,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) { @@ -241,9 +238,7 @@ class TimelineFragment : } } } - }.also { - binding.recyclerView.addOnScrollListener(it) - } + }) } eventHub.events @@ -279,8 +274,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) @@ -471,7 +465,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 { @@ -482,7 +476,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 12422a95..8b96283f 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 ) } @@ -140,6 +141,7 @@ fun Status.toEntity( contentCollapsed = contentCollapsed, pinned = actionableStatus.pinned == true, card = actionableStatus.card?.let(gson::toJson), + repliesCount = actionableStatus.repliesCount ) } @@ -183,6 +185,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { muted = status.muted, poll = poll, card = card, + repliesCount = status.repliesCount ) } val status = if (reblog != null) { @@ -211,7 +214,8 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { pinned = status.pinned, muted = status.muted, poll = null, - card = null + card = null, + repliesCount = status.repliesCount, ) } else { Status( @@ -240,6 +244,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { muted = status.muted, poll = poll, card = card, + repliesCount = status.repliesCount, ) } return StatusViewData.Concrete( 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 c4aa2c72..ebab4440 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 7158a7b3..97bc625b 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,10 +38,11 @@ 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.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 @@ -66,7 +68,16 @@ class CachedTimelineViewModel @Inject constructor( filterModel: FilterModel, private val db: AppDatabase, private val gson: Gson -) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel) { +) : TimelineViewModel( + timelineCases, + api, + eventHub, + accountManager, + sharedPreferences, + filterModel +) { + + private var currentPagingSource: PagingSource? = null @OptIn(ExperimentalPagingApi::class) override val statuses = Pager( @@ -78,6 +89,8 @@ class CachedTimelineViewModel @Inject constructor( EmptyTimelinePagingSource() } else { db.timelineDao().getStatuses(activeAccount.id) + }.also { newPagingSource -> + this.currentPagingSource = newPagingSource } } ).flow @@ -113,13 +126,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) } } @@ -146,12 +161,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() @@ -165,16 +189,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, @@ -193,7 +222,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) ) } } @@ -208,7 +240,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) { @@ -234,6 +267,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 ca7988bb..8c81df1d 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 @@ -249,6 +249,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 544d0818..d640f64f 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 @@ -39,8 +39,8 @@ import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll 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 @@ -81,6 +81,7 @@ abstract class TimelineViewModel( this.tags = tags 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 = @@ -172,6 +173,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 || @@ -287,6 +291,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 400eb073..5ffc9021 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 = "", @@ -64,7 +66,15 @@ data class AccountEntity( var activeNotifications: String = "[]", var emojis: List = emptyList(), var tabPreferences: List = defaultTabs(), - var notificationsFilter: String = "[\"follow_request\"]" + var notificationsFilter: String = "[\"follow_request\"]", + // Scope cannot be changed without re-login, so store it in case + // the scope needs to be changed in the future + var oauthScopes: String = "", + var unifiedPushUrl: String = "", + var pushPubKey: String = "", + var pushPrivKey: String = "", + var pushAuth: String = "", + var pushServerKey: String = "", ) { val identifier: String @@ -73,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 3de34f55..9c5e118b 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) { + fun addAccount( + accessToken: String, + domain: String, + clientId: String, + clientSecret: String, + oauthScopes: String, + newAccount: Account + ) { activeAccount?.let { it.isActive = false @@ -62,10 +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, isActive = true) + activeAccount = newAccountEntity + updateActiveAccount(newAccount) } /** @@ -86,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 @@ -100,7 +135,7 @@ class AccountManager @Inject constructor(db: AppDatabase) { } else { activeAccount = null } - return activeAccount + activeAccount } } @@ -120,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) } } @@ -189,4 +214,15 @@ class AccountManager @Inject constructor(db: AppDatabase) { id == accountId } } + + /** + * Finds an account by its string identifier + * @param identifier the string identifier of the account + * @return the requested account or null if it was not found + */ + fun getAccountByIdentifier(identifier: String): AccountEntity? { + return accounts.find { + identifier == it.identifier + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index d5f023e5..c43b3652 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -31,7 +31,7 @@ import java.io.File; */ @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, ConversationEntity.class - }, version = 35) + }, version = 39) public abstract class AppDatabase extends RoomDatabase { public abstract AccountDao accountDao(); @@ -541,4 +541,44 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `card` TEXT"); } }; + + public static final Migration MIGRATION_35_36 = new Migration(35, 36) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `oauthScopes` TEXT NOT NULL DEFAULT ''"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `unifiedPushUrl` TEXT NOT NULL DEFAULT ''"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushPubKey` TEXT NOT NULL DEFAULT ''"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushPrivKey` TEXT NOT NULL DEFAULT ''"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushAuth` TEXT NOT NULL DEFAULT ''"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushServerKey` TEXT NOT NULL DEFAULT ''"); + } + }; + + 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 fe093bd0..001dbbe5 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 9b190bc7..3687da09 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 2c6ef188..210bfca3 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, a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', @@ -197,4 +197,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 2c4d45c3..ecd3c0ce 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 0861e9cf..e17cb3cf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -64,6 +64,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_36_37, AppDatabase.MIGRATION_37_38, + AppDatabase.MIGRATION_38_39 ) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt index b7213fa6..e071fc84 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt @@ -16,8 +16,10 @@ package com.keylesspalace.tusky.di +import com.keylesspalace.tusky.receiver.NotificationBlockStateBroadcastReceiver import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver +import com.keylesspalace.tusky.receiver.UnifiedPushBroadcastReceiver import dagger.Module import dagger.android.ContributesAndroidInjector @@ -28,4 +30,10 @@ abstract class BroadcastReceiverModule { @ContributesAndroidInjector abstract fun contributeNotificationClearBroadcastReceiver(): NotificationClearBroadcastReceiver + + @ContributesAndroidInjector + abstract fun contributeUnifiedPushBroadcastReceiver(): UnifiedPushBroadcastReceiver + + @ContributesAndroidInjector + abstract fun contributeNotificationBlockStateBroadcastReceiver(): NotificationBlockStateBroadcastReceiver } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index 90dd3026..8250e61f 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 @@ -38,6 +40,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 @@ -50,7 +53,9 @@ class NetworkModule { @Provides @Singleton - fun providesGson() = Gson() + fun providesGson(): Gson = GsonBuilder() + .registerTypeAdapter(Date::class.java, Rfc3339DateJsonAdapter()) + .create() @Provides @Singleton @@ -106,7 +111,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/Account.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt index bf5431ee..4870c188 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt @@ -23,6 +23,7 @@ data class Account( @SerializedName("username") val localUsername: String, @SerializedName("acct") val username: String, @SerializedName("display_name") val displayName: String?, // should never be null per Api definition, but some servers break the contract + @SerializedName("created_at") val createdAt: Date, val note: String, val url: String, val avatar: String, diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt new file mode 100644 index 00000000..c6eb09be --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt @@ -0,0 +1,24 @@ +/* Copyright 2022 Tusky contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class NotificationSubscribeResult( + val id: Int, + val endpoint: String, + @SerializedName("server_key") val serverKey: String, +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index 19cb7aa6..72a37f91 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -34,6 +34,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 56291a21..32f32a2d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -538,7 +538,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)); ; } @@ -963,10 +963,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); } @@ -1231,7 +1231,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 ad81abe3..01a08c20 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 1864ac1c..4bab0f5c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -387,7 +387,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 00000000..bd8df6b5 --- /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 00000000..090fe5e3 --- /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 2dcedd87..00000000 --- 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 00000000..3ca7a811 --- /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 7357293b..e1d18e9f 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 @@ -30,6 +31,7 @@ import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.entity.MediaUploadResult import com.keylesspalace.tusky.entity.NewStatus import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.NotificationSubscribeResult import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.ScheduledStatus @@ -37,7 +39,6 @@ import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.StatusContext import com.keylesspalace.tusky.entity.TimelineAccount -import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Single import okhttp3.MultipartBody import okhttp3.RequestBody @@ -47,6 +48,7 @@ import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.Field +import retrofit2.http.FieldMap import retrofit2.http.FormUrlEncoded import retrofit2.http.GET import retrofit2.http.HTTP @@ -72,14 +74,11 @@ interface MastodonApi { const val PLACEHOLDER_DOMAIN = "dummy.placeholder" } - @GET("/api/v1/lists") - fun getLists(): Single> - @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> @@ -147,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( @@ -160,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( @@ -248,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") @@ -276,15 +278,23 @@ 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") - fun searchAccounts( + suspend fun searchAccounts( @Query("q") query: String, @Query("resolve") resolve: Boolean? = null, @Query("limit") limit: Int? = null, @Query("following") following: Boolean? = null - ): Single> + ): NetworkResult> + + @GET("api/v1/accounts/search") + fun searchAccountsSync( + @Query("q") query: String, + @Query("resolve") resolve: Boolean? = null, + @Query("limit") limit: Int? = null, + @Query("following") following: Boolean? = null + ): NetworkResult> @GET("api/v1/accounts/{id}") fun account( @@ -439,7 +449,7 @@ interface MastodonApi { @Field("redirect_uris") redirectUris: String, @Field("scopes") scopes: String, @Field("website") website: String - ): Result + ): NetworkResult @FormUrlEncoded @POST("oauth/token") @@ -450,52 +460,63 @@ 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(): NetworkResult> @FormUrlEncoded @POST("api/v1/lists") - fun createList( + suspend fun createList( @Field("title") title: String - ): Single + ): NetworkResult @FormUrlEncoded @PUT("api/v1/lists/{listId}") - fun updateList( + suspend fun updateList( @Path("listId") listId: String, @Field("title") title: String - ): Single + ): NetworkResult @DELETE("api/v1/lists/{listId}") - fun deleteList( + suspend fun deleteList( @Path("listId") listId: String - ): Completable + ): NetworkResult @GET("api/v1/lists/{listId}/accounts") - fun getAccountsInList( + suspend fun getAccountsInList( @Path("listId") listId: String, @Query("limit") limit: Int - ): Single> + ): NetworkResult> @FormUrlEncoded // @DELETE doesn't support fields @HTTP(method = "DELETE", path = "api/v1/lists/{listId}/accounts", hasBody = true) - fun deleteAccountFromList( + suspend fun deleteAccountFromList( @Path("listId") listId: String, @Field("account_ids[]") accountIds: List - ): Completable + ): NetworkResult @FormUrlEncoded @POST("api/v1/lists/{listId}/accounts") - fun addCountToList( + suspend fun addAccountToList( @Path("listId") listId: String, @Field("account_ids[]") accountIds: List - ): Completable + ): 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( @@ -538,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") @@ -591,10 +612,48 @@ interface MastodonApi { @Query("following") following: Boolean? = null ): Single + @GET("api/v2/search") + 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 + ): NetworkResult + @FormUrlEncoded @POST("api/v1/accounts/{id}/note") fun updateAccountNote( @Path("id") accountId: String, @Field("comment") note: String ): Single + + @FormUrlEncoded + @POST("api/v1/push/subscription") + suspend fun subscribePushNotifications( + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String, + @Field("subscription[endpoint]") endPoint: String, + @Field("subscription[keys][p256dh]") keysP256DH: String, + @Field("subscription[keys][auth]") keysAuth: String, + // The "data[alerts][]" fields to enable / disable notifications + // Should be generated dynamically from all the available notification + // types defined in [com.keylesspalace.tusky.entities.Notification.Types] + @FieldMap data: Map + ): NetworkResult + + @FormUrlEncoded + @PUT("api/v1/push/subscription") + suspend fun updatePushNotificationSubscription( + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String, + @FieldMap data: Map + ): NetworkResult + + @DELETE("api/v1/push/subscription") + suspend fun unsubscribePushNotifications( + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String, + ): 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 c7e9633f..a179e71d 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/receiver/NotificationBlockStateBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt new file mode 100644 index 00000000..20b18a9f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt @@ -0,0 +1,67 @@ +/* Copyright 2022 Tusky contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.receiver + +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import com.keylesspalace.tusky.components.notifications.canEnablePushNotifications +import com.keylesspalace.tusky.components.notifications.isUnifiedPushNotificationEnabledForAccount +import com.keylesspalace.tusky.components.notifications.updateUnifiedPushSubscription +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.network.MastodonApi +import dagger.android.AndroidInjection +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +@DelicateCoroutinesApi +class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() { + @Inject + lateinit var mastodonApi: MastodonApi + + @Inject + lateinit var accountManager: AccountManager + + override fun onReceive(context: Context, intent: Intent) { + AndroidInjection.inject(this, context) + if (Build.VERSION.SDK_INT < 28) return + if (!canEnablePushNotifications(context, accountManager)) return + + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + val gid = when (intent.action) { + NotificationManager.ACTION_NOTIFICATION_CHANNEL_BLOCK_STATE_CHANGED -> { + val channelId = intent.getStringExtra(NotificationManager.EXTRA_NOTIFICATION_CHANNEL_ID) + nm.getNotificationChannel(channelId).group + } + NotificationManager.ACTION_NOTIFICATION_CHANNEL_GROUP_BLOCK_STATE_CHANGED -> { + intent.getStringExtra(NotificationManager.EXTRA_NOTIFICATION_CHANNEL_GROUP_ID) + } + else -> null + } ?: return + + accountManager.getAccountByIdentifier(gid)?.let { account -> + if (isUnifiedPushNotificationEnabledForAccount(account)) { + // Update UnifiedPush notification subscription + GlobalScope.launch { updateUnifiedPushSubscription(context, mastodonApi, accountManager, account) } + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt new file mode 100644 index 00000000..45a5ae2b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt @@ -0,0 +1,80 @@ +/* Copyright 2022 Tusky contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.receiver + +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import com.keylesspalace.tusky.components.notifications.NotificationWorker +import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint +import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.network.MastodonApi +import dagger.android.AndroidInjection +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.unifiedpush.android.connector.MessagingReceiver +import javax.inject.Inject + +@DelicateCoroutinesApi +class UnifiedPushBroadcastReceiver : MessagingReceiver() { + companion object { + const val TAG = "UnifiedPush" + } + + @Inject + lateinit var accountManager: AccountManager + + @Inject + lateinit var mastodonApi: MastodonApi + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + AndroidInjection.inject(this, context) + } + + override fun onMessage(context: Context, message: ByteArray, instance: String) { + AndroidInjection.inject(this, context) + Log.d(TAG, "New message received for account $instance") + val workManager = WorkManager.getInstance(context) + val request = OneTimeWorkRequest.from(NotificationWorker::class.java) + workManager.enqueue(request) + } + + override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { + AndroidInjection.inject(this, context) + Log.d(TAG, "Endpoint available for account $instance: $endpoint") + accountManager.getAccountById(instance.toLong())?.let { + // Launch the coroutine in global scope -- it is short and we don't want to lose the registration event + // and there is no saner way to use structured concurrency in a receiver + GlobalScope.launch { registerUnifiedPushEndpoint(context, mastodonApi, accountManager, it, endpoint) } + } + } + + override fun onRegistrationFailed(context: Context, instance: String) = Unit + + override fun onUnregistered(context: Context, instance: String) { + AndroidInjection.inject(this, context) + Log.d(TAG, "Endpoint unregistered for account $instance") + accountManager.getAccountById(instance.toLong())?.let { + // It's fine if the account does not exist anymore -- that means it has been logged out + GlobalScope.launch { unregisterUnifiedPushEndpoint(mastodonApi, accountManager, it) } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt index e50f4f4f..20ad8de8 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 6540601a..ee92fc2d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -62,6 +62,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 00000000..f8d3b11c --- /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 86148e51..8f114434 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/CryptoUtil.kt b/app/src/main/java/com/keylesspalace/tusky/util/CryptoUtil.kt new file mode 100644 index 00000000..f4fa4b5b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/CryptoUtil.kt @@ -0,0 +1,60 @@ +/* Copyright 2022 Tusky contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util + +import android.util.Base64 +import org.bouncycastle.jce.ECNamedCurveTable +import org.bouncycastle.jce.interfaces.ECPrivateKey +import org.bouncycastle.jce.interfaces.ECPublicKey +import org.bouncycastle.jce.provider.BouncyCastleProvider +import java.security.KeyPairGenerator +import java.security.SecureRandom +import java.security.Security + +object CryptoUtil { + const val CURVE_PRIME256_V1 = "prime256v1" + + private const val BASE64_FLAGS = Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + + init { + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) + Security.addProvider(BouncyCastleProvider()) + } + + private fun secureRandomBytes(len: Int): ByteArray { + val ret = ByteArray(len) + SecureRandom.getInstance("SHA1PRNG").nextBytes(ret) + return ret + } + + fun secureRandomBytesEncoded(len: Int): String { + return Base64.encodeToString(secureRandomBytes(len), BASE64_FLAGS) + } + + data class EncodedKeyPair(val pubkey: String, val privKey: String) + + fun generateECKeyPair(curve: String): EncodedKeyPair { + val spec = ECNamedCurveTable.getParameterSpec(curve) + val gen = KeyPairGenerator.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME) + gen.initialize(spec) + val keyPair = gen.genKeyPair() + val pubKey = keyPair.public as ECPublicKey + val privKey = keyPair.private as ECPrivateKey + val encodedPubKey = Base64.encodeToString(pubKey.q.getEncoded(false), BASE64_FLAGS) + val encodedPrivKey = Base64.encodeToString(privKey.d.toByteArray(), BASE64_FLAGS) + return EncodedKeyPair(encodedPubKey, encodedPrivKey) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt new file mode 100644 index 00000000..26f96255 --- /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 8ac212d9..bef7d0e1 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. */ @@ -106,7 +106,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 fd989376..aafe4ce0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt @@ -17,92 +17,104 @@ 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 import com.keylesspalace.tusky.util.Either.Left import com.keylesspalace.tusky.util.Either.Right -import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.withoutFirstWhich -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.subjects.BehaviorSubject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import javax.inject.Inject data class State(val accounts: Either>, val searchResult: List?) -class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : RxAwareViewModel() { +class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() { - val state: Observable get() = _state - private val _state = BehaviorSubject.createDefault(State(Right(listOf()), null)) + val state: Flow get() = _state + private val _state = MutableStateFlow(State(Right(listOf()), null)) fun load(listId: String) { - val state = _state.value!! + val state = _state.value if (state.accounts.isLeft() || state.accounts.asRight().isEmpty()) { - api.getAccountsInList(listId, 0).subscribe( - { accounts -> - updateState { copy(accounts = Right(accounts)) } - }, - { e -> - updateState { copy(accounts = Left(e)) } - } - ).autoDispose() + viewModelScope.launch { + api.getAccountsInList(listId, 0).fold( + { accounts -> + updateState { copy(accounts = Right(accounts)) } + }, + { e -> + updateState { copy(accounts = Left(e)) } + } + ) + } } } fun addAccountToList(listId: String, account: TimelineAccount) { - api.addCountToList(listId, listOf(account.id)) - .subscribe( - { - updateState { - copy(accounts = accounts.map { it + account }) + viewModelScope.launch { + api.addAccountToList(listId, listOf(account.id)) + .fold( + { + updateState { + copy(accounts = accounts.map { it + account }) + } + }, + { + Log.i( + javaClass.simpleName, + "Failed to add account to list: ${account.username}" + ) } - }, - { - Log.i( - javaClass.simpleName, - "Failed to add account to the list: ${account.username}" - ) - } - ) - .autoDispose() + ) + } } fun deleteAccountFromList(listId: String, accountId: String) { - api.deleteAccountFromList(listId, listOf(accountId)) - .subscribe( - { - updateState { - copy( - accounts = accounts.map { accounts -> - accounts.withoutFirstWhich { it.id == accountId } - } + viewModelScope.launch { + api.deleteAccountFromList(listId, listOf(accountId)) + .fold( + { + updateState { + copy( + accounts = accounts.map { accounts -> + accounts.withoutFirstWhich { it.id == accountId } + } + ) + } + }, + { + Log.i( + javaClass.simpleName, + "Failed to remove account from list: $accountId" ) } - }, - { - Log.i(javaClass.simpleName, "Failed to remove account from thelist: $accountId") - } - ) - .autoDispose() + ) + } } fun search(query: String) { when { query.isEmpty() -> updateState { copy(searchResult = null) } query.isBlank() -> updateState { copy(searchResult = listOf()) } - else -> api.searchAccounts(query, null, 10, true) - .subscribe( - { result -> - updateState { copy(searchResult = result) } - }, - { - updateState { copy(searchResult = listOf()) } - } - ).autoDispose() + else -> viewModelScope.launch { + api.searchAccounts(query, null, 10, true) + .fold( + { result -> + updateState { copy(searchResult = result) } + }, + { + updateState { copy(searchResult = listOf()) } + } + ) + } } } private inline fun updateState(crossinline fn: State.() -> State) { - _state.onNext(fn(_state.value!!)) + _state.value = fn(_state.value) } } 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 17aa38c7..bc7f435d 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 68263155..4c755f86 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt @@ -16,19 +16,23 @@ 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.RxAwareViewModel import com.keylesspalace.tusky.util.replacedFirstWhich import com.keylesspalace.tusky.util.withoutFirstWhich -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.subjects.BehaviorSubject -import io.reactivex.rxjava3.subjects.PublishSubject +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import java.io.IOException import java.net.ConnectException import javax.inject.Inject -internal class ListsViewModel @Inject constructor(private val api: MastodonApi) : RxAwareViewModel() { +internal class ListsViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() { enum class LoadingState { INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER } @@ -39,86 +43,94 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi) data class State(val lists: List, val loadingState: LoadingState) - val state: Observable get() = _state - val events: Observable get() = _events - private val _state = BehaviorSubject.createDefault(State(listOf(), LoadingState.INITIAL)) - private val _events = PublishSubject.create() + val state: Flow get() = _state + val events: Flow get() = _events + private val _state = MutableStateFlow(State(listOf(), LoadingState.INITIAL)) + private val _events = MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) fun retryLoading() { loadIfNeeded() } private fun loadIfNeeded() { - val state = _state.value!! + val state = _state.value if (state.loadingState == LoadingState.LOADING || state.lists.isNotEmpty()) return updateState { copy(loadingState = LoadingState.LOADING) } - api.getLists().subscribe( - { lists -> - updateState { - copy( - lists = lists, - loadingState = LoadingState.LOADED - ) + viewModelScope.launch { + api.getLists().fold( + { lists -> + updateState { + copy( + lists = lists, + loadingState = LoadingState.LOADED + ) + } + }, + { err -> + updateState { + copy( + loadingState = if (err is IOException || err is ConnectException) + LoadingState.ERROR_NETWORK else LoadingState.ERROR_OTHER + ) + } } - }, - { err -> - updateState { - copy( - loadingState = if (err is IOException || err is ConnectException) - LoadingState.ERROR_NETWORK else LoadingState.ERROR_OTHER - ) - } - } - ).autoDispose() + ) + } } fun createNewList(listName: String) { - api.createList(listName).subscribe( - { list -> - updateState { - copy(lists = lists + list) + viewModelScope.launch { + api.createList(listName).fold( + { list -> + updateState { + copy(lists = lists + list) + } + }, + { + sendEvent(Event.CREATE_ERROR) } - }, - { - sendEvent(Event.CREATE_ERROR) - } - ).autoDispose() + ) + } } fun renameList(listId: String, listName: String) { - api.updateList(listId, listName).subscribe( - { list -> - updateState { - copy(lists = lists.replacedFirstWhich(list) { it.id == listId }) + viewModelScope.launch { + api.updateList(listId, listName).fold( + { list -> + updateState { + copy(lists = lists.replacedFirstWhich(list) { it.id == listId }) + } + }, + { + sendEvent(Event.RENAME_ERROR) } - }, - { - sendEvent(Event.RENAME_ERROR) - } - ).autoDispose() + ) + } } fun deleteList(listId: String) { - api.deleteList(listId).subscribe( - { - updateState { - copy(lists = lists.withoutFirstWhich { it.id == listId }) + viewModelScope.launch { + api.deleteList(listId).fold( + { + updateState { + copy(lists = lists.withoutFirstWhich { it.id == listId }) + } + }, + { + sendEvent(Event.DELETE_ERROR) } - }, - { - sendEvent(Event.DELETE_ERROR) - } - ).autoDispose() + ) + } } private inline fun updateState(crossinline fn: State.() -> State) { - _state.onNext(fn(_state.value!!)) + _state.value = fn(_state.value) } - private fun sendEvent(event: Event) { - _events.onNext(event) + private suspend fun sendEvent(event: Event) { + _events.emit(event) } } diff --git a/app/src/main/res/layout/activity_account.xml b/app/src/main/res/layout/activity_account.xml index 28e12cad..562a1020 100644 --- a/app/src/main/res/layout/activity_account.xml +++ b/app/src/main/res/layout/activity_account.xml @@ -235,6 +235,19 @@ tools:itemCount="2" tools:listitem="@layout/item_account_field" /> + + + + @@ -27,8 +27,9 @@ android:layout_width="match_parent" android:layout_height="wrap_content" app:tabGravity="fill" + app:tabMaxWidth="0dp" app:tabMode="fixed" - app:tabTextAppearance="@style/TuskyTabAppearance"/> + app:tabTextAppearance="@style/TuskyTabAppearance" /> @@ -38,6 +39,6 @@ android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior" /> - + \ No newline at end of file diff --git a/app/src/main/res/layout/item_autocomplete_account.xml b/app/src/main/res/layout/item_autocomplete_account.xml index 681f9919..000bae53 100644 --- a/app/src/main/res/layout/item_autocomplete_account.xml +++ b/app/src/main/res/layout/item_autocomplete_account.xml @@ -1,48 +1,65 @@ - + android:paddingStart="16dp" + android:paddingTop="8dp" + android:paddingEnd="16dp" + android:paddingBottom="8dp"> - + - + - + - - - \ No newline at end of file + diff --git a/app/src/main/res/layout/item_autocomplete_divider.xml b/app/src/main/res/layout/item_autocomplete_divider.xml deleted file mode 100644 index f9b211b0..00000000 --- a/app/src/main/res/layout/item_autocomplete_divider.xml +++ /dev/null @@ -1,5 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_autocomplete_emoji.xml b/app/src/main/res/layout/item_autocomplete_emoji.xml index 2f910040..fbc2f5c9 100644 --- a/app/src/main/res/layout/item_autocomplete_emoji.xml +++ b/app/src/main/res/layout/item_autocomplete_emoji.xml @@ -5,24 +5,24 @@ android:layout_height="wrap_content" android:gravity="center_vertical" android:orientation="horizontal" - android:padding="8dp"> + tools:ignore="UseCompoundDrawables"> + android:layout_marginStart="16dp" + android:layout_marginTop="8dp" + android:layout_marginBottom="8dp" + android:importantForAccessibility="no" /> + tools:text="#Tusky" /> diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml index 477d05ba..faf9010e 100644 --- a/app/src/main/res/layout/item_status.xml +++ b/app/src/main/res/layout/item_status.xml @@ -319,6 +319,16 @@ app:layout_constraintTop_toBottomOf="@id/status_poll_description" app:srcCompat="@drawable/ic_reply_24dp" /> + + إضافة حساب ماستدون جديد القوائم القوائم - الخط الزمني للقائمة لا يمكن إنشاء قائمة لا يمكن إعادة تسمية القائمة لا يمكن حذف القائمة diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 092db218..a58d3eaf 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -151,7 +151,6 @@ Списъкът не можа да се изтрие Списъкът не можа да се създаде Списъкът не можа да се преименува - Списъчна емисия Списъци Списъци Добавяне на нов Mastodon акаунт diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml index 8354954e..080e4e94 100644 --- a/app/src/main/res/values-bn-rBD/strings.xml +++ b/app/src/main/res/values-bn-rBD/strings.xml @@ -75,7 +75,6 @@ তালিকা মুছে ফেলা যায়নি তালিকা নামকরণ করা যায়নি তালিকা তৈরি করা যায়নি - তালিকা টাইমলাইনে রাখুন তালিকাসমূহ তালিকাসমূহ নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন diff --git a/app/src/main/res/values-bn-rIN/strings.xml b/app/src/main/res/values-bn-rIN/strings.xml index 1233cc2b..a44d773a 100644 --- a/app/src/main/res/values-bn-rIN/strings.xml +++ b/app/src/main/res/values-bn-rIN/strings.xml @@ -275,7 +275,6 @@ নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন তালিকাসমূহ তালিকাসমূহ - তালিকা টাইমলাইনে রাখুন তালিকা তৈরি করা যায়নি তালিকা নামকরণ করা যায়নি তালিকা মুছে ফেলা যায়নি diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 40c74c9b..2d38e326 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -275,7 +275,6 @@ Afegir un compte de Mastodont Llistes Llistes - Cronologia de la llista És impossible crear la llista Impossible reanomenar la llista És impossible suprimir la llista diff --git a/app/src/main/res/values-ckb/strings.xml b/app/src/main/res/values-ckb/strings.xml index ba275b0b..985ec63a 100644 --- a/app/src/main/res/values-ckb/strings.xml +++ b/app/src/main/res/values-ckb/strings.xml @@ -403,7 +403,6 @@ نەیتوانی لیستەکە بسڕێتەوە نەیتوانی ناوی لیست بنووسرێ نەیتوانی لیست دروست بکات - لیستی تایم لاین لیستەکان لیستەکان زیادکردنی ئەژمێری ماتۆدۆنی نوێ diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index badd5f21..4aa248c4 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -198,7 +198,7 @@ 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í @@ -274,7 +274,6 @@ Přidat nový účet Mastodon Seznamy Seznamy - Časová osa seznamu Nelze vytvořit seznam Nelze přejmenovat seznam Nelze smazat seznam @@ -484,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. \ No newline at end of file diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml index 4b9d9a62..b6773d20 100644 --- a/app/src/main/res/values-cy/strings.xml +++ b/app/src/main/res/values-cy/strings.xml @@ -236,7 +236,6 @@ Ychwanegu cyfrif Mastodon newydd Rhestri Rhestri - Amserlen rhestri Yn postio â chyfrif %1$s Methu gosod pennawd Pennu pennawd diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index fe8f871a..507b74be 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -256,7 +256,6 @@ Neues Mastodon-Konto hinzufügen Listen Listen - Liste Liste erstellen Liste umbenennen Liste löschen @@ -537,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. \ No newline at end of file diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index 891c96b8..b562f7fe 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -271,7 +271,6 @@ Aldoni novan Mastodon konton Listoj Listoj - Tempolinio de la listo Ne povis krei la liston Ne povis ŝanĝi la nomon de la listo Ne povis forigi la liston diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 788d643f..edab5f59 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -251,7 +251,6 @@ Añadir cuenta de Mastodon Listas Listas - Cronología de lista Publicando con la cuenta %1$s Error al añadir leyenda diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index fd0c057a..1de2d88b 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -235,7 +235,6 @@ Mastodon kontua gehitu Zerrendak Zerrendak - Zerrenda denbora-lerroa %1$s kontuarekin tut egiten Akatsa deskribapena eranstean diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index d0b2556d..5d715500 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://git.chinwag.org/chinwag/chinwag-android/issues نمایهٔ تاسکی - هم‌رسانی محتوای بوق - هم‌رسانی پیوند بوق + هم‌رسانی محتوای فرسته + هم‌رسانی پیوند فرسته تصویرها ویدیو تقاضای پیگیری شد @@ -229,7 +229,6 @@ افزودن حساب ماستودون جدید فهرست‌ها فهرست‌ها - خط زمانی فهرست در حال فرستادن با حساب %1$s شکست در تنظیم عنوان @@ -241,19 +240,19 @@ قفل حساب لازم است پی‌گیران را دستی تأیید کنید ذخیرهٔ پیش‌نویس؟ - در حال فرستادن بوق… - خطای فرستادن بوق - در حال فرستادن بوق‌ها + فرستادن فرسته… + خطا در فرستادن فرسته + فرستادن فرسته‌ها فرستادن لغو شد - رونوشتی از بوق در پیش‌نویس‌هایتان ذخیره شد + رونوشتی از فرسته در پیش‌نویس‌هایتان ذخیره شد ایجاد نمونه‌تان %s هیچ اموجی سفارشی‌ای ندارد سبک اموجی پیش‌گزیدهٔ سامانه نخست باید این مجموعه‌های اموجی را بارگیری کنید در حال جست‌وجو… - گسترده/جمع کردن تمام وضعیت‌ها - گشودن بوق + گسترش/جمع کردن تمام فرسته‌ها + گشودن فرسته نیاز به آغاز دوبارهٔ کاره برای اعمال این تغییرات، نیاز به شروع دوبارهٔ تاسکی دارید بعداً @@ -279,7 +278,7 @@ یک خطای شبکه رخ داد! لطفا اتصال خود را بررسی و دوباره تلاش کنید! پیام‌های مستقیم زبانه‌ها - سنجاق‌شده + سنجاق شده دامنه‌های نهفته \@%s این‌جا هیچ‌چیزی نیست. @@ -305,7 +304,7 @@ بارگیری رسانه در حال بارگیری رسانه %s نانهفته - می‌خواهید این بوق را پاک و بازنویسی کنید؟ + حذف و بازنویسی این فرسته؟ نهفتن تمام دامنه پایان نظرسنجی‌ها پالایه‌ها @@ -321,7 +320,7 @@ %d ساعت %d دقیقه %d ثانیه - گسترش همیشگی بوق‌های علامت‌خورده با هشدار محتوا + گسترش همیشگی فرسته‌های علامت‌خورده با هشدار محتوا خط زمانی‌های عمومی گفت‌وگوها افزودن پالایه @@ -361,8 +360,8 @@ رسانه: %s هشدار محتوا: %s - بدون هیچ توضیحی - بازبوقیده + بدون شرح + تقویت شده برگزیده عمومی فهرست‌نشده @@ -374,7 +373,7 @@ پاک‌سازی پالایش اعمال - ایجاد بوق + ایجاد فرسته ایجاد مطمئنید می‌خواهید تمام آگاهی‌هایتان را برای همیشه پاک کنید؟ پایان در %s @@ -401,7 +400,7 @@ نظرهای اضافی هدایت به %s شکست در گزارش - شکست در واکشی وضعیت‌ها + شکست در واکشی فرسته‌ها حساب‌ها شکست در جست‌وجو نمایش پالایهٔ آگاهی‌ها @@ -417,10 +416,10 @@ گزینه‌های چندگانه گزینهٔ %d ویرایش - بوق‌های زمان‌بسته + فرسته‌های زمان‌بسته ویرایش - بوق‌های زمان‌بسته - بوق زمان‌بسته + فرسته‌های زمان‌بسته + فرستهٔ زمان‌بسته بازنشانی مطمئنید می‌خواهید تمام %s را مسدود کنید؟ محتوای آن دامنه را در هیچ‌یک از خط زمانی‌ها یا در آگاهی‌هایتان نخواهید دید. پی‌گیرانتان از آن دامنه، برداشته خواهند شد. هنگامی که کلیدواژه یا عبارت، فقط حروف‌عددی باشد، فقط اگر با تمام واژه مطابق باشد، اعمال خواهد شد @@ -438,7 +437,7 @@ گزینش فهرست فهرست هیچ پیش‌نویسی ندارید. - هیچ وضعیت زمان‌بسته‌ای ندارید. + هیچ فرستهٔ زمان‌بسته‌ای ندارید. ماستودون، بازهٔ زمان‌بندی‌ای با کمینهٔ ۵ دقیقه دارد. نمایش گفت‌وگوی تأیید، پیش از تقویت پیش‌نمایش پیوندها در خط‌زمانی‌ها @@ -483,7 +482,7 @@ عدم اشتراک اشتراک پیش‌نویس حذف شد - فرستادن این بوق شکست خورد! + فرستادن این فرسته شکست خورد! نهفتن آمار کمی روی نمایه‌ها نهفتن آمار کمی روی فرسته‌ها محدود کردن آگاهی‌های خط‌زمانی @@ -492,17 +491,17 @@ طول پیوست‌ها صدا - آگاهی‌ها هنگام انتشار بوقی جدید از کسی که مشترکش هستید - بوق‌های جدید + آگاهی‌ها هنگام انتشار فرسته‌ای جدید از کسی که پی‌می‌گیرید + فرسته‌های جدید اموجی‌های شخصی متحرّک - کسی که مشترکش شده‌ام، بوقی جدید منتشر کرد + کسی که پی‌می‌گیرم، فرسته‌ای جدید منتشر کرد %s چیزی فرستاد - بوقی که پاسخی به آن را پیش‌نویس کردید، برداشته شده + فرسته‌ای که پاسخی به آن را پیش‌نویس کردید، برداشته شده شکست در بار کردن اطّلاعات پاسخ برخی اطّلاعات که ممکن است روی سلامتی ذهنیتان تأثیر بگذارد، پنهان خواهند شد. همچون: \n \n - آگاهی‌های برگزیدن، تقویت و پی‌گیری -\n - شمار برگزیدن و تقویت بوق‌ها +\n - شمار برگزیدن و تقویت فرسته‌ها \n - آمار پی‌گیر و فرسته روی نمایه‌ها \n \n فرستادن آگاهی‌ها تأثیر نمی‌پذیرد، ولی می‌توانید ترجیحات آگاهیتان را به صورت دستی بازبینی کنید. @@ -515,4 +514,34 @@ با این که حسابتان قفل نیست، کارکنان %1$s فکر کردند ممکن است بخواهید درخواست‌های پی‌گیری از این حساب‌ها را دستی بازبینی کنید. حذف این گفت‌وگو؟ حذف گفت‌وگو + در %1$s پیوست + ورود + %s ثبت‌نام کرد + نمایش گفت‌وگوی تأیید پیش از برگزیدن + ایجاد فرسته + ورود دوباره به تمامی حساب‌ها برای به کار انداختن پشتیبانی آگاهی‌های ارسالی. + آگاهی‌ها هنگام ویرایش فرسته‌هایی که با آن‌ها تعامل داشته‌اید + برداشن نشانک + برای اعطای اجازهٔ اشتراک آگاهی‌های ارسالی ، دوباره به حسابتان وارد شدید. با این حال هنوز حساب‌هایی دیگر دارید که این‌گونه مهاجرت داده نشده‌اند. به آن‌ها رفته و برای به کار انداختن پشتیبانی آگاهی‌های UnifiedPush یکی‌یکی دوباره وارد شوید. + مطمئنید که می‌خواهید از حساب %1$s خارج شوید؟ + ۱۴ روز + ۳۰ روز + ۶۰ روز + ۹۰ روز + ۳۶۵ روز + ۱۸۰ روز + ۱+ + تاسکی برای استفاده از آگاهی‌های ارسالی با UnifiedPush نیاز به اجازهٔ اشتراک آگاهی‌ها روی کارساز ماستودنتان دارد. این کار نیازمند ورود دوباره برای تغییر حوزه‌های OAuth اعطایی به تاسکی است. استفاده از گزینهٔ ورود دوباره در این‌جا یا در ترجیحات حساب، تمامی انباره‌ها و پیش‌نویس‌های محلیتان را نگه خواهد داشت. + نتوانست صفحهٔ ورود را بار کند. + کسی ثبت‌نام کرد + ویرایش‌های فرسته + ویرایش تصویر + %s فرسته‌اش را ویراست + فرسته‌ای که با آن تعامل داشته‌ام ویرایش شده + ثبت‌نام‌ها + آگاهی‌ها دربارهٔ کاربران جدید + ورود دوباره برای آگاهی‌های ارسالی + رد کردن + جزییات + ذخیرهٔ پیش‌نویس… \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 987e777b..06ddc238 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://git.chinwag.org/chinwag/chinwag-android/issues Profil de Tusky - 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 @@ -275,7 +275,6 @@ Ajouter un nouveau compte Mastodon Listes Listes - Fil de la liste Impossible de créer la liste Impossible de renommer la liste Impossible de supprimer la liste @@ -297,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 Tusky pour appliquer ces modifications Plus tard @@ -351,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é @@ -378,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 @@ -391,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 @@ -419,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é @@ -445,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 @@ -458,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 \? @@ -529,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 @@ -541,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. \ No newline at end of file diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index 121ed937..f74f8370 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -310,7 +310,6 @@ Theip ar stádas a fháil Seolfar an tuarascáil chuig do mhodhnóir freastalaí. Féadfaidh tú míniú a thabhairt ar an bhfáth go bhfuil tú ag tuairisciú an chuntais seo thíos: Cuir Cuntas Mastodon nua leis - Liostaigh amlíne Níorbh fhéidir liosta a chruthú Níorbh fhéidir an liosta a athainmniú Níorbh fhéidir an liosta a scriosadh diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index 317331dc..294bdc56 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -270,7 +270,6 @@ Cha b’ urrainn dhuinn an liosta a sguabadh às Cha b’ urrainn dhut ainm ùr a thoirt air an liosta Cha b’ urrainn dhuinn an liosta a chruthachadh - Loidhne-ama na liosta Cuir cunntas Mastodon ùr ris Cuir cunntas ris An abairt ri chriathradh @@ -557,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 5b488df3..af350478 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 @@ -275,7 +275,6 @@ Non se puido eliminar a listaxe Non se puido renomear a listaxe Non se puido crear a listaxe - Cronoloxía da listaxe Listaxes Listaxes Engadir unha nova conta Mastodon @@ -522,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-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 8f537f12..6d84611f 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -248,7 +248,6 @@ लिखने को सुरक्षित करें\? खाता लॉक करें कैप्शन सेट करें - सूची टाइमलाइन खाता जोड़ो पूरा शब्द फ़िल्टर संपादित करें diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 728f9ae9..fc74f129 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -336,7 +336,6 @@ %dmp múlva Teljes szó Ha a kulcsszó csak alfanumerikus karakterekből áll, csak teljes szóra fog illeszkedni - Lista idővonal Általad követettek keresése Fiók hozzáadása a listához Fiók eltávolítása a listából @@ -534,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 \ No newline at end of file diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index f6241b86..576ce3ee 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -288,7 +288,6 @@ Frasi sem á að sía Bæta við aðgang Bæta við nýjum Mastodon-aðgangi - Lista upp tímalínu Ekki tókst að búa til lista Ekki tókst að endurnefna lista Ekki tókst að eyða lista @@ -526,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 fcd93b10..ed445196 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 @@ -269,7 +269,6 @@ Aggiungi un nuovo Account Mastodon Liste Liste - Timeline della lista Non è stato possibile creare la lista Non è stato possibile rinominare la lista Non è stato possibile eliminare la lista @@ -313,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 Tusky contiene codice e risorse dai seguenti progetti open source: Licenziata sotto la Licenza Apache (copia sotto) CC-BY 4.0 @@ -335,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 @@ -403,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 @@ -471,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. @@ -489,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 @@ -516,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 @@ -541,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 \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index ff978bf3..5af565f8 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -255,7 +255,6 @@ 新しいMastodonアカウントを追加 リスト リスト - リストタイムライン リスト名を変更できませんでした リスト名の変更 %1$sで投稿 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 6921eb4b..7e1de7cc 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -282,7 +282,6 @@ 마스토돈 계정을 추가합니다 리스트 리스트 - 리스트 타임라인 리스트를 만들 수 없습니다. 리스트의 이름을 변경할 수 없습니다. 리스트를 삭제할 수 없습니다. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 0552be7f..bb956f01 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -259,7 +259,6 @@ Een nieuw Mastodonaccount toevoegen Lijsten Lijsten - Tijdlijn lijst Aan het publiceren met account %1$s Toevoegen van beschrijving mislukt diff --git a/app/src/main/res/values-no-rNB/strings.xml b/app/src/main/res/values-no-rNB/strings.xml index 8890c9dd..e93c8eea 100644 --- a/app/src/main/res/values-no-rNB/strings.xml +++ b/app/src/main/res/values-no-rNB/strings.xml @@ -242,7 +242,6 @@ Legg til ny Mastodon-konto Lister Lister - Listetidslinje Kunne ikke opprette liste Kunne ikke gi liste nytt navn Kunne ikke slette liste @@ -528,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 \ No newline at end of file diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml index 89cbd0dc..9a7b5e2d 100644 --- a/app/src/main/res/values-oc/strings.xml +++ b/app/src/main/res/values-oc/strings.xml @@ -228,7 +228,6 @@ Apondre un nòu compte Mastodon Listas Listas - Flux de la lista Publicar amb lo compte %1$s Fracàs en apondre una legenda Apondre una legenda diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index e6573c15..ed07e68c 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -229,7 +229,6 @@ Dodaj nowe Konto Mastodon Listy Listy - Oś czasu listy Publikowanie z konta %1$s Nie udało się ustawić podpisu Ustaw podpis diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index e6af0372..d01f4590 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -246,7 +246,6 @@ Adicionar nova conta Mastodon Listas Listas - Linha da lista Usando a conta %1$s Erro ao incluir descrição Descrever diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 6be06b0c..e7d56559 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 - Cronologia da timeline Não foi possível criar a lista Não foi possível apagar a lista Criar uma lista diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 67af8cc7..e7a76456 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -296,7 +296,6 @@ Добавить новый акканут Mastodon Списки Списки - Список лент Не удалось создать список Не удалось переименовать список Не удалось удалить список diff --git a/app/src/main/res/values-sa/strings.xml b/app/src/main/res/values-sa/strings.xml index 4c8ac044..b2f03446 100644 --- a/app/src/main/res/values-sa/strings.xml +++ b/app/src/main/res/values-sa/strings.xml @@ -202,7 +202,6 @@ पुनः सूचिनामकरणं कर्तुमशक्यम् सूचिनिर्माणं कर्तुमशक्यम् अनुसरणानुरोधो नश्यताम् \? - सूचेः समयतालिका सूचयः सूचयः नवमास्टोडोनलेखा युज्यताम् diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index f6d6caad..e24f55ac 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -247,7 +247,6 @@ Dodaj nov Mastodon račun Seznami Seznami - Seznam časovnice Seznama ni bilo mogoče ustvariti Seznama ni bilo mogoče preimenovati Seznama ni bilo mogoče izbrisati diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index f9908d87..0d446fd3 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -269,7 +269,6 @@ Lägg till ett nytt Mastodon-konto Listor Listor - Lista tidslinje Kunde inte skapa lista Kunde inte byta namn på lista Kunde inte radera lista diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index f199adf4..dba2311b 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -216,7 +216,6 @@ புதிய Mastodon கணக்கைச் சேர்க்க பட்டியல்கள் பட்டியல்கள் - காலவரிசை பட்டியல் %1$s கணக்குடன் பதிவிட தலைப்பை அமைக்க முடியவில்லை தலைப்பை அமை diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 781a432b..beb1a3e8 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -144,7 +144,6 @@ ไม่สามารถลบรายการได้ ไม่สามารถเปลี่ยนชื่อรายการได้ ไม่สามารถสร้างรายการได้ - ไทม์ไลน์ในรายการ เพิ่มบัญชี Mastodon ใหม่ เพิ่มบัญชี วลีที่ต้องการกรอง diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index dea620be..41029173 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -243,7 +243,6 @@ Yeni Mastodon hesabı ekle Listeler Listeler - Zaman çizelgesini listele %1$s hesabıyla gönderiliyor Görsel engelli için tanımla diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 5998c9b0..1a970944 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -285,7 +285,6 @@ Не вдалося видалити список Не вдалося перейменувати список Не вдалося створити список - Стрічка списку Додати новий обліковий запис Mastodon Додати обліковий запис Фільтрувати фразу @@ -551,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 125e452e..fba2310e 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -448,7 +448,6 @@ Xóa danh sách Đổi tên danh sách Tạo danh sách - Danh sách bảng tin Thêm tài khoản Mastodon Thêm tài khoản Thêm mô tả @@ -518,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 2e6a8042..722543ca 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 提及了你 @@ -277,7 +277,6 @@ 添加新的 Mastodon 帐号 列表 列表 - 列表时间轴 无法新建列表 无法重命名列表 无法删除列表 @@ -338,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 等人 @@ -355,7 +354,7 @@ 内容警告:%s 没有描述信息 被转嘟 - 被收藏 + 被喜欢 公开 @@ -494,12 +493,12 @@ 反馈通知 隐藏嘟文的统计信息 限制时间线通知 - 一些可能影响您精神状态的信息将被隐藏,这些信息包括: -\n -\n - 收藏、转发、关注通知 -\n - 收藏、转发数 -\n - 账号的已关注数量、嘟文数量 -\n + 一些可能影响您精神状态的信息将被隐藏,这些信息包括: +\n +\n - 喜欢、转发、关注通知 +\n - 喜欢、转发数 +\n - 账号的已关注数量、嘟文数量 +\n \n 推送通知不会被影响,但可以在通知设置中手动禁用。 健康模式 永久 @@ -517,7 +516,7 @@ 即使您的账号未上锁,管理员 %1$s 认为您可能需要手动处理来自这些账号的关注请求。 删除此对话吗? 删除对话 - 收藏前显示确认对话框 + 喜欢前显示确认对话框 删除书签 30 天 60 天 @@ -537,4 +536,15 @@ 当你进行过互动的嘟文被编辑时发出通知 无法加载登录页。 正在保存草稿… + 重新登陆以启用通知推送 + 不理会 + 详情 + 你已重新登录当前账户,向 Tusky 授予推送订阅权限。但是,你仍然有其他没有以这种方式迁移的账户。切换到它们,逐个重新登录,以启用 UnifiedPush 通知支持。 + 加入于%1$s + 重新登录所有账户来启用推送通知支持。 + 为了通过 UnifiedPush 使用推送通知,Tusky 需要订阅你 Mastodon 服务器通知的权限。这需要重新登录来更改授予 Tusky 的 OAuth 作用域。使用此处或账户首选项中“重新登录”选项将保留你所有的本地草稿和缓存。 + 1+ + 编辑图片 + 无法编辑图片。 + 加载账户详情失败 \ No newline at end of file diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 5ac0003a..26863f15 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -276,7 +276,6 @@ 加入新的 Mastodon 帳號 列表 列表 - 列表時間軸 無法新建列表 無法重命名列表 無法刪除列表 diff --git a/app/src/main/res/values-zh-rMO/strings.xml b/app/src/main/res/values-zh-rMO/strings.xml index 4ea493d6..caceb922 100644 --- a/app/src/main/res/values-zh-rMO/strings.xml +++ b/app/src/main/res/values-zh-rMO/strings.xml @@ -270,7 +270,6 @@ 加入新的 Mastodon 帳號 列表 列表 - 列表時間軸 無法新建列表 無法重命名列表 無法刪除列表 diff --git a/app/src/main/res/values-zh-rSG/strings.xml b/app/src/main/res/values-zh-rSG/strings.xml index dc729241..ab16016a 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 提及了你 @@ -274,7 +274,6 @@ 添加新的 Mastodon 帐号 列表 列表 - 列表时间轴 无法新建列表 无法重命名列表 无法删除列表 @@ -334,13 +333,13 @@ 取消置顶 置顶 - <b>%1$s</b> 次收藏 + %1$s 次喜欢 <b>%s</b> 次转嘟 转嘟 - 收藏 + 喜欢 %1$s %1$s 和 %2$s %1$s, %2$s 和 %3$d 等人 @@ -359,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 b5776edd..b40dc573 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 提及了你 @@ -276,7 +276,6 @@ 加入新的 Mastodon 帳號 列表 列表 - 列表時間軸 無法新建列表 無法重命名列表 無法刪除列表 @@ -336,13 +335,13 @@ 取消置頂 置頂 - <b>%1$s</b> 次收藏 + %1$s 次最愛 <b>%s</b> 次轉嘟 轉嘟 - 收藏由 + 最愛由 %1$s %1$s 和 %2$s %1$s, %2$s 和 %3$d 等人 @@ -361,9 +360,7 @@ 被轉嘟 - - 被收藏 - + 被最愛 公開 @@ -445,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 44160f62..03f076a9 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. @@ -40,6 +42,7 @@ Muted users Blocked users Hidden domains + Re-login for push notifications Follow Requests Edit your profile Drafts @@ -147,6 +150,8 @@ Open boost author Show boosts Show favorites + Dismiss + Details Hashtags Mentions @@ -345,6 +350,7 @@ Video Audio Attachments + 1+ Follow requested @@ -382,7 +388,6 @@ Lists Lists - List timeline Could not create list Could not rename list Could not delete list @@ -401,6 +406,7 @@ Describe for visually impaired\n(%d character limit) Set caption + Edit image Remove Lock account Requires you to manually approve followers @@ -641,6 +647,14 @@ Register New Account Compose Post + + Joined %1$s + Saving draft… + Re-login all accounts to enable push notification support. + In order to use push notifications via UnifiedPush, Tusky needs permission to subscribe to notifications on your Mastodon server. This requires a re-login to change the OAuth scopes granted to Tusky. Using the re-login option here or in Account Preferences will preserve all of your local drafts and cache. + You have re-logged into your current account to grant push subscription permission to Tusky. However, you still have other accounts that have not been migrated this way. Switch to them and re-login one by one in order to enable UnifiedPush notifications support. + + diff --git a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt index beb6af9b..503a0317 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 3a8f2f23..ef863560 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 @@ -47,6 +48,8 @@ import org.robolectric.Robolectric import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.fakes.RoboMenuItem +import java.util.Date +import kotlin.collections.HashMap /** * Created by charlag on 3/7/18. @@ -65,6 +68,8 @@ class ComposeActivityTest { id = 1, domain = instanceDomain, accessToken = "token", + clientId = "id", + clientSecret = "secret", isActive = true, accountId = "1", username = "username", @@ -93,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) } } } @@ -466,22 +471,23 @@ class ComposeActivityTest { null, listOf("en"), Account( - "1", - "admin", - "admin", - "admin", - "", - "https://example.token", - "", - "", - false, - 0, - 0, - 0, - null, - false, - emptyList(), - emptyList() + id = "1", + localUsername = "admin", + username = "admin", + displayName = "admin", + createdAt = Date(), + note = "", + url = "https://example.token", + avatar = "", + header = "", + locked = false, + statusesCount = 0, + followersCount = 0, + followingCount = 0, + source = null, + bot = false, + emojis = emptyList(), + fields = emptyList(), ), maximumLegacyTootCharacters, null, diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeTokenizerTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeTokenizerTest.kt index e203dde2..fa0bba94 100644 --- a/app/src/test/java/com/keylesspalace/tusky/ComposeTokenizerTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/ComposeTokenizerTest.kt @@ -15,7 +15,7 @@ package com.keylesspalace.tusky -import com.keylesspalace.tusky.util.ComposeTokenizer +import com.keylesspalace.tusky.components.compose.ComposeTokenizer import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith diff --git a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt index 91ea38d3..521f01d6 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 2778f8c2..c117cf59 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 eabf744c..808540de 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 cc6a90bd..8781f6d9 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 ed652418..620f7340 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 00000000..aa070489 --- /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/build.gradle b/build.gradle index 3a5251fa..725ab8da 100644 --- a/build.gradle +++ b/build.gradle @@ -5,8 +5,8 @@ buildscript { gradlePluginPortal() } dependencies { - classpath "com.android.tools.build:gradle:7.1.2" - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.20" + classpath "com.android.tools.build:gradle:7.2.0" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21" classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.1" } } diff --git a/fastlane/metadata/android/de/changelogs/94.txt b/fastlane/metadata/android/de/changelogs/94.txt new file mode 100644 index 00000000..711131ca --- /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 00000000..8dc15d85 --- /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 00000000..71c5a0ac --- /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 00000000..e385800d --- /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 00000000..15cbac96 --- /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 00000000..00017693 --- /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 00000000..0e4befb4 --- /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 00000000..c9ad649e --- /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 00000000..fe40fdf1 --- /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 00000000..fe3c8e0d --- /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 00000000..954a8018 --- /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 00000000..a31309fd --- /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 00000000..b3f8aa27 --- /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