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