diff --git a/app/build.gradle b/app/build.gradle index a6ceea876..9805d9670 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -95,7 +95,7 @@ android { } } -ext.coroutinesVersion = "1.6.0" +ext.coroutinesVersion = "1.6.1" ext.lifecycleVersion = "2.4.1" ext.roomVersion = '2.4.2' ext.retrofitVersion = '2.9.0' @@ -112,8 +112,6 @@ repositories { // if libraries are changed here, they should also be changed in LicenseActivity dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion" @@ -150,6 +148,7 @@ dependencies { implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" implementation "com.squareup.retrofit2:adapter-rxjava3:$retrofitVersion" + implementation "at.connyduck:kotlin-result-calladapter:1.0.1" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion" @@ -189,8 +188,8 @@ dependencies { testImplementation "androidx.test.ext:junit:1.1.3" testImplementation "org.robolectric:robolectric:4.4" - testImplementation "org.mockito:mockito-inline:3.6.28" - testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0" + testImplementation "org.mockito:mockito-inline:4.4.0" + testImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0" androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0" androidTestImplementation "androidx.room:room-testing:$roomVersion" diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/32.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/32.json new file mode 100644 index 000000000..97ad414e0 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/32.json @@ -0,0 +1,815 @@ +{ + "formatVersion": 1, + "database": { + "version": 32, + "identityHash": "c92343960c9d46d9cfd49f1873cce47d", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c92343960c9d46d9cfd49f1873cce47d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/33.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/33.json new file mode 100644 index 000000000..e6d8ec7dc --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/33.json @@ -0,0 +1,809 @@ +{ + "formatVersion": 1, + "database": { + "version": 33, + "identityHash": "920a0e0c9a600bd236f6bf959b469c18", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '920a0e0c9a600bd236f6bf959b469c18')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 46654b625..271eb2c16 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,6 +22,22 @@ android:theme="@style/TuskyTheme" android:usesCleartextTraffic="false"> + + + + + + + + + + + @@ -30,13 +46,7 @@ - - - - - @@ -84,9 +94,6 @@ - - onFetchUserInfoSuccess(userInfo) - }, - { throwable -> - Log.e(TAG, "Failed to fetch user info. " + throwable.message) - } - ) + private fun fetchUserInfo() = lifecycleScope.launch { + mastodonApi.accountVerifyCredentials().fold( + { userInfo -> + onFetchUserInfoSuccess(userInfo) + }, + { throwable -> + Log.e(TAG, "Failed to fetch user info. " + throwable.message) + } + ) } private fun onFetchUserInfoSuccess(me: Account) { diff --git a/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt new file mode 100644 index 000000000..7f621f9b5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt @@ -0,0 +1,54 @@ +/* Copyright 2018 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 + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.keylesspalace.tusky.components.login.LoginActivity +import com.keylesspalace.tusky.components.notifications.NotificationHelper +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.Injectable +import net.accelf.yuito.CustomUncaughtExceptionHandler +import javax.inject.Inject + +@SuppressLint("CustomSplashScreen") +class SplashActivity : AppCompatActivity(), Injectable { + + @Inject + lateinit var accountManager: AccountManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + Thread.setDefaultUncaughtExceptionHandler(CustomUncaughtExceptionHandler(applicationContext)) + + /** delete old notification channels */ + NotificationHelper.deleteLegacyNotificationChannels(this, accountManager) + + /** Determine whether the user is currently logged in, and if so go ahead and load the + * timeline. Otherwise, start the activity_login screen. */ + + val intent = if (accountManager.activeAccount != null) { + Intent(this, MainActivity::class.java) + } else { + LoginActivity.getIntent(this, false) + } + startActivity(intent) + finish() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt index 64d29577b..fda2c82b6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -283,7 +283,6 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener } return@fromCallable false } - .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doOnDispose { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index 68f921013..747ce4bfb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -41,6 +41,7 @@ import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding; +import com.keylesspalace.tusky.databinding.ViewQuoteInlineBinding; import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Status; @@ -229,7 +230,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { case VIEW_TYPE_FOLLOW: { if (payloadForHolder == null) { FollowViewHolder holder = (FollowViewHolder) viewHolder; - holder.setMessage(concreteNotificaton.getAccount()); + holder.setMessage(concreteNotificaton.getAccount(), concreteNotificaton.getType() == Notification.Type.SIGN_UP); holder.setupButtons(notificationActionListener, concreteNotificaton.getAccount().getId()); } break; @@ -287,7 +288,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter { case REBLOG: { return VIEW_TYPE_STATUS_NOTIFICATION; } - case FOLLOW: { + case FOLLOW: + case SIGN_UP: { return VIEW_TYPE_FOLLOW; } case FOLLOW_REQUEST: { @@ -339,10 +341,10 @@ public class NotificationsAdapter extends RecyclerView.Adapter { this.statusDisplayOptions = statusDisplayOptions; } - void setMessage(TimelineAccount account) { + void setMessage(TimelineAccount account, Boolean isSignUp) { Context context = message.getContext(); - String format = context.getString(R.string.notification_follow_format); + String format = context.getString(isSignUp ? R.string.notification_sign_up_format : R.string.notification_follow_format); String wrappedDisplayName = StringUtils.unicodeWrap(account.getName()); String wholeMessage = String.format(format, wrappedDisplayName); CharSequence emojifiedMessage = CustomEmojiHelper.emojify( @@ -599,13 +601,14 @@ public class NotificationsAdapter extends RecyclerView.Adapter { avatarRadius24dp, statusDisplayOptions.animateAvatars()); } - private void setQuoteContainer(Status status, final LinkListener listener, StatusDisplayOptions statusDisplayOptions) { - if (status != null) { + private void setQuoteContainer(StatusViewData.Concrete quote, final LinkListener listener, StatusDisplayOptions statusDisplayOptions) { + if (quote != null) { quoteContainer.setVisibility(View.VISIBLE); - new QuoteInlineHelper(status, quoteContainer, listener, + ViewQuoteInlineBinding binding = ViewQuoteInlineBinding.bind(quoteContainer); + new QuoteInlineHelper(binding, listener, quoteContainer.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp), statusDisplayOptions) - .setupQuoteContainer(); + .setupQuoteContainer(quote); } else { quoteContainer.setVisibility(View.GONE); } @@ -678,7 +681,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { } contentWarningDescriptionTextView.setText(emojifiedContentWarning); - setQuoteContainer(statusViewData.getStatus().getQuote(), listener, statusDisplayOptions); + setQuoteContainer(statusViewData.getQuoteViewData(), listener, statusDisplayOptions); } } 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 2b37289f1..83411d671 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -33,6 +33,7 @@ import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners; import com.google.android.material.button.MaterialButton; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.ViewMediaActivity; +import com.keylesspalace.tusky.databinding.ViewQuoteInlineBinding; import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Attachment.Focus; import com.keylesspalace.tusky.entity.Attachment.MetaData; @@ -480,10 +481,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { favouriteButton.setChecked(favourited); } - private void setQuoteContainer(Status status, final StatusActionListener listener, StatusDisplayOptions statusDisplayOptions) { - if (status != null) { + private void setQuoteContainer(StatusViewData.Concrete quote, final StatusActionListener listener, StatusDisplayOptions statusDisplayOptions) { + if (quote != null) { quoteContainer.setVisibility(View.VISIBLE); - new QuoteInlineHelper(status, quoteContainer, listener, avatarRadius24dp, statusDisplayOptions).setupQuoteContainer(); + ViewQuoteInlineBinding binding = ViewQuoteInlineBinding.bind(quoteContainer); + new QuoteInlineHelper(binding, listener, avatarRadius24dp, statusDisplayOptions).setupQuoteContainer(quote); } else { quoteContainer.setVisibility(View.GONE); } @@ -857,7 +859,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { actionable.getAccount().getBot(), statusDisplayOptions); setReblogged(actionable.getReblogged()); setFavourited(actionable.getFavourited()); - setQuoteContainer(actionable.getQuote(), listener, statusDisplayOptions); + setQuoteContainer(status.getQuoteViewData(), listener, statusDisplayOptions); setBookmarked(actionable.getBookmarked()); List attachments = actionable.getAttachments(); boolean sensitive = actionable.getSensitive(); @@ -1152,9 +1154,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { StatusDisplayOptions statusDisplayOptions, final StatusActionListener listener ) { - final Card card = status.getActionable().getCard(); + final Status actionable = status.getActionable(); + final Card card = actionable.getCard(); if (cardViewMode != CardViewMode.NONE && - status.getActionable().getAttachments().size() == 0 && + actionable.getAttachments().size() == 0 && + actionable.getPoll() == null && card != null && !TextUtils.isEmpty(card.getUrl()) && (!status.isCollapsible() || !status.isCollapsed())) { @@ -1176,7 +1180,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { // Statuses from other activitypub sources can be marked sensitive even if there's no media, // so let's blur the preview in that case // If media previews are disabled, show placeholder for cards as well - if (statusDisplayOptions.mediaPreviewEnabled() && !status.getActionable().getSensitive() && !TextUtils.isEmpty(card.getImage())) { + if (statusDisplayOptions.mediaPreviewEnabled() && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) { int topLeftRadius = 0; int topRightRadius = 0; 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 12b2fa35b..33987604f 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 @@ -78,7 +78,7 @@ import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.getDomain import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.loadAvatar -import com.keylesspalace.tusky.util.openLink +import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding @@ -380,12 +380,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI } } viewModel.accountFieldData.observe( - this, - { - accountFieldAdapter.fields = it - accountFieldAdapter.notifyDataSetChanged() - } - ) + this + ) { + accountFieldAdapter.fields = it + accountFieldAdapter.notifyDataSetChanged() + } viewModel.noteSaved.observe(this) { binding.saveNoteInfo.visible(it, View.INVISIBLE) } @@ -400,11 +399,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI adapter.refreshContent() } viewModel.isRefreshing.observe( - this, - { isRefreshing -> - binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true - } - ) + this + ) { isRefreshing -> + binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true + } binding.swipeToRefreshLayout.setColorSchemeResources(R.color.tusky_blue) } @@ -415,7 +413,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI binding.accountUsernameTextView.text = usernameFormatted binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis) - val emojifiedNote = account.note.emojify(account.emojis, binding.accountNoteTextView, animateEmojis) + val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis) setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this) // accountFieldAdapter.fields = account.fields ?: emptyList() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt index 093dbcfb5..d51bb1452 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt @@ -29,6 +29,7 @@ import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.createClickableText import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.setClickableText class AccountFieldAdapter( @@ -65,7 +66,7 @@ class AccountFieldAdapter( val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis) nameTextView.text = emojifiedName - val emojifiedValue = field.value.emojify(emojis, valueTextView, animateEmojis) + val emojifiedValue = field.value.parseAsMastodonHtml().emojify(emojis, valueTextView, animateEmojis) setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener) if (field.verifiedAt != null) { 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 d1ae0b9e3..10dc303f6 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 @@ -35,6 +35,7 @@ import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.Success import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.rx3.rxSingle import javax.inject.Inject class AnnouncementsViewModel @Inject constructor( @@ -56,8 +57,9 @@ class AnnouncementsViewModel @Inject constructor( appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) .map> { Either.Left(it) } .onErrorResumeNext { - mastodonApi.getInstance() - .map { Either.Right(it) } + rxSingle { + mastodonApi.getInstance().getOrThrow() + }.map { Either.Right(it) } } ) { emojis, either -> either.asLeftOrNull()?.copy(emojiList = emojis) 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 0764a03df..dc1253e80 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 @@ -48,7 +48,8 @@ import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.Disposable import kotlinx.coroutines.launch -import java.util.* +import kotlinx.coroutines.rx3.rxSingle +import java.util.Locale import javax.inject.Inject class ComposeViewModel @Inject constructor( @@ -110,7 +111,7 @@ class ComposeViewModel @Inject constructor( fun loadInstanceDataFromNetwork(loadActually: Boolean) { when (loadActually) { true -> Single.zip( - api.getCustomEmojis(), api.getInstance() + api.getCustomEmojis(), rxSingle { api.getInstance().getOrThrow() } ) { emojis, instance -> InstanceEntity( instance = accountManager.activeAccount?.domain!!, @@ -298,7 +299,7 @@ class ComposeViewModel @Inject constructor( ): LiveData { val deletionObservable = if (isEditingScheduledToot) { - api.deleteScheduledStatus(scheduledTootId.toString()).toObservable().map { } + rxSingle { api.deleteScheduledStatus(scheduledTootId.toString()) }.toObservable().map { } } else { Observable.just(Unit) }.toLiveData() 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 89c1ad0f1..0c9465142 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt @@ -26,7 +26,7 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions class ConversationAdapter( private val statusDisplayOptions: StatusDisplayOptions, private val listener: StatusActionListener -) : PagingDataAdapter(CONVERSATION_COMPARATOR) { +) : PagingDataAdapter(CONVERSATION_COMPARATOR) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder { val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false) @@ -37,17 +37,13 @@ class ConversationAdapter( holder.setupWithConversation(getItem(position)) } - fun item(position: Int): ConversationEntity? { - return getItem(position) - } - companion object { - val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean { + val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean { return oldItem.id == newItem.id } - override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean { + override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean { return oldItem == newItem } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index a847fe749..19e8ba041 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -15,7 +15,6 @@ package com.keylesspalace.tusky.components.conversation -import android.text.Spanned import androidx.room.Embedded import androidx.room.Entity import androidx.room.TypeConverters @@ -27,7 +26,7 @@ import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.TimelineAccount -import com.keylesspalace.tusky.util.shouldTrimStatus +import com.keylesspalace.tusky.viewdata.StatusViewData import java.util.Date @Entity(primaryKeys = ["id", "accountId"]) @@ -38,7 +37,16 @@ data class ConversationEntity( val accounts: List, val unread: Boolean, @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity -) +) { + fun toViewData(): ConversationViewData { + return ConversationViewData( + id = id, + accounts = accounts, + unread = unread, + lastStatus = lastStatus.toViewData() + ) + } +} data class ConversationAccountEntity( val id: String, @@ -67,7 +75,7 @@ data class ConversationStatusEntity( val inReplyToId: String?, val inReplyToAccountId: String?, val account: ConversationAccountEntity, - val content: Spanned, + val content: String, val createdAt: Date, val emojis: List, val favouritesCount: Int, @@ -80,96 +88,44 @@ data class ConversationStatusEntity( val tags: List?, val showingHiddenContent: Boolean, val expanded: Boolean, - val collapsible: Boolean, val collapsed: Boolean, val muted: Boolean, val poll: Poll? ) { - /** its necessary to override this because Spanned.equals does not work as expected */ - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - other as ConversationStatusEntity - - if (id != other.id) return false - if (url != other.url) return false - if (inReplyToId != other.inReplyToId) return false - if (inReplyToAccountId != other.inReplyToAccountId) return false - if (account != other.account) return false - if (content.toString() != other.content.toString()) return false - if (createdAt != other.createdAt) return false - if (emojis != other.emojis) return false - if (favouritesCount != other.favouritesCount) return false - if (favourited != other.favourited) return false - if (sensitive != other.sensitive) return false - if (spoilerText != other.spoilerText) return false - if (attachments != other.attachments) return false - if (mentions != other.mentions) return false - if (tags != other.tags) return false - if (showingHiddenContent != other.showingHiddenContent) return false - if (expanded != other.expanded) return false - if (collapsible != other.collapsible) return false - if (collapsed != other.collapsed) return false - if (muted != other.muted) return false - if (poll != other.poll) return false - - return true - } - - override fun hashCode(): Int { - var result = id.hashCode() - result = 31 * result + (url?.hashCode() ?: 0) - result = 31 * result + (inReplyToId?.hashCode() ?: 0) - result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0) - result = 31 * result + account.hashCode() - result = 31 * result + content.toString().hashCode() - result = 31 * result + createdAt.hashCode() - result = 31 * result + emojis.hashCode() - result = 31 * result + favouritesCount - result = 31 * result + favourited.hashCode() - result = 31 * result + sensitive.hashCode() - result = 31 * result + spoilerText.hashCode() - result = 31 * result + attachments.hashCode() - result = 31 * result + mentions.hashCode() - result = 31 * result + tags.hashCode() - result = 31 * result + showingHiddenContent.hashCode() - result = 31 * result + expanded.hashCode() - result = 31 * result + collapsible.hashCode() - result = 31 * result + collapsed.hashCode() - result = 31 * result + muted.hashCode() - result = 31 * result + poll.hashCode() - return result - } - - fun toStatus(): Status { - return Status( - id = id, - url = url, - account = account.toAccount(), - inReplyToId = inReplyToId, - inReplyToAccountId = inReplyToAccountId, - content = content, - reblog = null, - createdAt = createdAt, - emojis = emojis, - reblogsCount = 0, - favouritesCount = favouritesCount, - reblogged = false, - favourited = favourited, - bookmarked = bookmarked, - sensitive = sensitive, - spoilerText = spoilerText, - visibility = Status.Visibility.DIRECT, - attachments = attachments, - mentions = mentions, - tags = tags, - application = null, - pinned = false, - muted = muted, - poll = poll, - card = null, - quote = null, + fun toViewData(): StatusViewData.Concrete { + return StatusViewData.Concrete( + status = Status( + id = id, + url = url, + account = account.toAccount(), + inReplyToId = inReplyToId, + inReplyToAccountId = inReplyToAccountId, + content = content, + reblog = null, + createdAt = createdAt, + emojis = emojis, + reblogsCount = 0, + favouritesCount = favouritesCount, + reblogged = false, + favourited = favourited, + bookmarked = bookmarked, + sensitive = sensitive, + spoilerText = spoilerText, + visibility = Status.Visibility.DIRECT, + attachments = attachments, + mentions = mentions, + tags = tags, + application = null, + pinned = false, + muted = muted, + poll = poll, + card = null, + quote = null, + ), + isExpanded = expanded, + isShowingContent = showingHiddenContent, + isCollapsed = collapsed ) } } @@ -203,7 +159,6 @@ fun Status.toEntity() = tags = tags, showingHiddenContent = false, expanded = false, - collapsible = shouldTrimStatus(content), collapsed = true, muted = muted ?: false, poll = poll diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt new file mode 100644 index 000000000..470675d17 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt @@ -0,0 +1,87 @@ +/* Copyright 2022 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.conversation + +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.viewdata.StatusViewData + +data class ConversationViewData( + val id: String, + val accounts: List, + val unread: Boolean, + val lastStatus: StatusViewData.Concrete +) { + fun toEntity( + accountId: Long, + favourited: Boolean = lastStatus.status.favourited, + bookmarked: Boolean = lastStatus.status.bookmarked, + muted: Boolean = lastStatus.status.muted ?: false, + poll: Poll? = lastStatus.status.poll, + expanded: Boolean = lastStatus.isExpanded, + collapsed: Boolean = lastStatus.isCollapsed, + showingHiddenContent: Boolean = lastStatus.isShowingContent + ): ConversationEntity { + return ConversationEntity( + accountId = accountId, + id = id, + accounts = accounts, + unread = unread, + lastStatus = lastStatus.toConversationStatusEntity( + favourited = favourited, + bookmarked = bookmarked, + muted = muted, + poll = poll, + expanded = expanded, + collapsed = collapsed, + showingHiddenContent = showingHiddenContent + ) + ) + } +} + +fun StatusViewData.Concrete.toConversationStatusEntity( + favourited: Boolean = status.favourited, + bookmarked: Boolean = status.bookmarked, + muted: Boolean = status.muted ?: false, + poll: Poll? = status.poll, + expanded: Boolean = isExpanded, + collapsed: Boolean = isCollapsed, + showingHiddenContent: Boolean = isShowingContent +): ConversationStatusEntity { + return ConversationStatusEntity( + id = id, + url = status.url, + inReplyToId = status.inReplyToId, + inReplyToAccountId = status.inReplyToAccountId, + account = status.account.toEntity(), + content = status.content, + createdAt = status.createdAt, + emojis = status.emojis, + favouritesCount = status.favouritesCount, + favourited = favourited, + bookmarked = bookmarked, + sensitive = status.sensitive, + spoilerText = status.spoilerText, + attachments = status.attachments, + mentions = status.mentions, + tags = status.tags, + showingHiddenContent = showingHiddenContent, + expanded = expanded, + collapsed = collapsed, + muted = muted, + poll = poll + ) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index 6f95619c7..55c85fcdc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -28,11 +28,14 @@ import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; import com.keylesspalace.tusky.entity.Attachment; +import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.entity.TimelineAccount; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.SmartLengthInputFilter; import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.viewdata.PollViewDataKt; +import com.keylesspalace.tusky.viewdata.StatusViewData; import java.util.List; @@ -69,11 +72,12 @@ public class ConversationViewHolder extends StatusBaseViewHolder { return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height); } - void setupWithConversation(ConversationEntity conversation) { - ConversationStatusEntity status = conversation.getLastStatus(); - ConversationAccountEntity account = status.getAccount(); + void setupWithConversation(ConversationViewData conversation) { + StatusViewData.Concrete statusViewData = conversation.getLastStatus(); + Status status = statusViewData.getStatus(); + TimelineAccount account = status.getAccount(); - setupCollapsedState(status.getCollapsible(), status.getCollapsed(), status.getExpanded(), status.getSpoilerText(), listener); + setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener); setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions); setUsername(account.getUsername()); @@ -84,7 +88,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { List attachments = status.getAttachments(); boolean sensitive = status.getSensitive(); if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { - setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent(), + setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(), statusDisplayOptions.useBlurhash()); if (attachments.size() == 0) { @@ -95,7 +99,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { mediaLabel.setVisibility(View.GONE); } } else { - setMediaLabel(attachments, sensitive, listener, status.getShowingHiddenContent()); + setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent()); // Hide all unused views. mediaPreviews[0].setVisibility(View.GONE); mediaPreviews[1].setVisibility(View.GONE); @@ -104,10 +108,10 @@ public class ConversationViewHolder extends StatusBaseViewHolder { hideSensitiveMediaWarning(); } - setupButtons(listener, account.getId(), status.getContent().toString(), + setupButtons(listener, account.getId(), statusViewData.getContent().toString(), false, statusDisplayOptions); - setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(), + setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(), status.getMentions(), status.getTags(), status.getEmojis(), PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index 0859799fb..08f97b5bd 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 @@ -104,7 +104,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res initSwipeToRefresh() - lifecycleScope.launch { + viewLifecycleOwner.lifecycleScope.launch { viewModel.conversationFlow.collectLatest { pagingData -> adapter.submitData(pagingData) } @@ -155,7 +155,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onFavourite(favourite: Boolean, position: Int) { - adapter.item(position)?.let { conversation -> + adapter.peek(position)?.let { conversation -> viewModel.favourite(favourite, conversation) } } @@ -165,18 +165,18 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onBookmark(favourite: Boolean, position: Int) { - adapter.item(position)?.let { conversation -> + adapter.peek(position)?.let { conversation -> viewModel.bookmark(favourite, conversation) } } override fun onMore(view: View, position: Int) { - adapter.item(position)?.let { conversation -> + adapter.peek(position)?.let { conversation -> val popup = PopupMenu(requireContext(), view) popup.inflate(R.menu.conversation_more) - if (conversation.lastStatus.muted) { + if (conversation.lastStatus.status.muted == true) { popup.menu.removeItem(R.id.status_mute_conversation) } else { popup.menu.removeItem(R.id.status_unmute_conversation) @@ -195,14 +195,14 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - adapter.item(position)?.let { conversation -> - viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.toStatus()), view) + adapter.peek(position)?.let { conversation -> + viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.status), view) } } override fun onViewThread(position: Int) { - adapter.item(position)?.let { conversation -> - viewThread(conversation.lastStatus.id, conversation.lastStatus.url) + adapter.peek(position)?.let { conversation -> + viewThread(conversation.lastStatus.id, conversation.lastStatus.status.url) } } @@ -211,13 +211,13 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onExpandedChange(expanded: Boolean, position: Int) { - adapter.item(position)?.let { conversation -> + adapter.peek(position)?.let { conversation -> viewModel.expandHiddenStatus(expanded, conversation) } } override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - adapter.item(position)?.let { conversation -> + adapter.peek(position)?.let { conversation -> viewModel.showContent(isShowing, conversation) } } @@ -227,7 +227,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - adapter.item(position)?.let { conversation -> + adapter.peek(position)?.let { conversation -> viewModel.collapseLongStatus(isCollapsed, conversation) } } @@ -247,12 +247,12 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onReply(position: Int) { - adapter.item(position)?.let { conversation -> - reply(conversation.lastStatus.toStatus()) + adapter.peek(position)?.let { conversation -> + reply(conversation.lastStatus.status) } } - private fun deleteConversation(conversation: ConversationEntity) { + private fun deleteConversation(conversation: ConversationViewData) { AlertDialog.Builder(requireContext()) .setMessage(R.string.dialog_delete_conversation_warning) .setNegativeButton(android.R.string.cancel, null) @@ -274,7 +274,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onVoteInPoll(position: Int, choices: MutableList) { - adapter.item(position)?.let { conversation -> + adapter.peek(position)?.let { conversation -> viewModel.voteInPoll(choices, conversation) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt index 396f8e486..9326a05c0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt @@ -16,16 +16,18 @@ package com.keylesspalace.tusky.components.conversation import android.util.Log +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn +import androidx.paging.map import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.TimelineCases -import com.keylesspalace.tusky.util.RxAwareViewModel +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.await import javax.inject.Inject @@ -35,7 +37,7 @@ class ConversationsViewModel @Inject constructor( private val database: AppDatabase, private val accountManager: AccountManager, private val api: MastodonApi -) : RxAwareViewModel() { +) : ViewModel() { @OptIn(ExperimentalPagingApi::class) val conversationFlow = Pager( @@ -44,104 +46,117 @@ class ConversationsViewModel @Inject constructor( pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) } ) .flow + .map { pagingData -> + pagingData.map { conversation -> conversation.toViewData() } + } .cachedIn(viewModelScope) - fun favourite(favourite: Boolean, conversation: ConversationEntity) { + fun favourite(favourite: Boolean, conversation: ConversationViewData) { viewModelScope.launch { try { timelineCases.favourite(conversation.lastStatus.id, favourite).await() - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(favourited = favourite) + val newConversation = conversation.toEntity( + accountId = accountManager.activeAccount!!.id, + favourited = favourite ) - database.conversationDao().insert(newConversation) + saveConversationToDb(newConversation) } catch (e: Exception) { Log.w(TAG, "failed to favourite status", e) } } } - fun bookmark(bookmark: Boolean, conversation: ConversationEntity) { + fun bookmark(bookmark: Boolean, conversation: ConversationViewData) { viewModelScope.launch { try { timelineCases.bookmark(conversation.lastStatus.id, bookmark).await() - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(bookmarked = bookmark) + val newConversation = conversation.toEntity( + accountId = accountManager.activeAccount!!.id, + bookmarked = bookmark ) - database.conversationDao().insert(newConversation) + saveConversationToDb(newConversation) } catch (e: Exception) { Log.w(TAG, "failed to bookmark status", e) } } } - fun voteInPoll(choices: List, conversation: ConversationEntity) { + fun voteInPoll(choices: List, conversation: ConversationViewData) { viewModelScope.launch { try { - val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.poll?.id!!, choices).await() - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(poll = poll) + val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices).await() + val newConversation = conversation.toEntity( + accountId = accountManager.activeAccount!!.id, + poll = poll ) - database.conversationDao().insert(newConversation) + saveConversationToDb(newConversation) } catch (e: Exception) { Log.w(TAG, "failed to vote in poll", e) } } } - fun expandHiddenStatus(expanded: Boolean, conversation: ConversationEntity) { + fun expandHiddenStatus(expanded: Boolean, conversation: ConversationViewData) { viewModelScope.launch { - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(expanded = expanded) + val newConversation = conversation.toEntity( + accountId = accountManager.activeAccount!!.id, + expanded = expanded ) saveConversationToDb(newConversation) } } - fun collapseLongStatus(collapsed: Boolean, conversation: ConversationEntity) { + fun collapseLongStatus(collapsed: Boolean, conversation: ConversationViewData) { viewModelScope.launch { - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(collapsed = collapsed) + val newConversation = conversation.toEntity( + accountId = accountManager.activeAccount!!.id, + collapsed = collapsed ) saveConversationToDb(newConversation) } } - fun showContent(showing: Boolean, conversation: ConversationEntity) { + fun showContent(showing: Boolean, conversation: ConversationViewData) { viewModelScope.launch { - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing) + val newConversation = conversation.toEntity( + accountId = accountManager.activeAccount!!.id, + showingHiddenContent = showing ) saveConversationToDb(newConversation) } } - fun remove(conversation: ConversationEntity) { + fun remove(conversation: ConversationViewData) { viewModelScope.launch { try { api.deleteConversation(conversationId = conversation.id) - database.conversationDao().delete(conversation) + database.conversationDao().delete( + id = conversation.id, + accountId = accountManager.activeAccount!!.id + ) } catch (e: Exception) { Log.w(TAG, "failed to delete conversation", e) } } } - fun muteConversation(conversation: ConversationEntity) { + fun muteConversation(conversation: ConversationViewData) { viewModelScope.launch { try { - val newStatus = timelineCases.muteConversation( + timelineCases.muteConversation( conversation.lastStatus.id, - !conversation.lastStatus.muted + !(conversation.lastStatus.status.muted ?: false) ).await() - val newConversation = conversation.copy( - lastStatus = newStatus.toEntity() + val newConversation = conversation.toEntity( + accountId = accountManager.activeAccount!!.id, + muted = !(conversation.lastStatus.status.muted ?: false) ) database.conversationDao().insert(newConversation) @@ -151,7 +166,7 @@ class ConversationsViewModel @Inject constructor( } } - suspend fun saveConversationToDb(conversation: ConversationEntity) { + private suspend fun saveConversationToDb(conversation: ConversationEntity) { database.conversationDao().insert(conversation) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt index a95e1cf8b..dc3c16962 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 @@ -33,7 +33,6 @@ import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ActivityLoginBinding import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.entity.AppCredentials import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.viewBinding @@ -159,32 +158,33 @@ class LoginActivity : BaseActivity(), Injectable { setLoading(true) lifecycleScope.launch { - val credentials: AppCredentials = try { - mastodonApi.authenticateApp( - domain, getString(R.string.app_name), oauthRedirectUri, - OAUTH_SCOPES, getString(R.string.tusky_website) - ) - } catch (e: Exception) { - binding.loginButton.isEnabled = true - binding.domainTextInputLayout.error = - getString(R.string.error_failed_app_registration) - setLoading(false) - Log.e(TAG, Log.getStackTraceString(e)) - return@launch - } + mastodonApi.authenticateApp( + domain, getString(R.string.app_name), oauthRedirectUri, + OAUTH_SCOPES, getString(R.string.tusky_website) + ).fold( + { credentials -> + // Before we open browser page we save the data. + // Even if we don't open other apps user may go to password manager or somewhere else + // and we will need to pick up the process where we left off. + // Alternatively we could pass it all as part of the intent and receive it back + // but it is a bit of a workaround. + preferences.edit() + .putString(DOMAIN, domain) + .putString(CLIENT_ID, credentials.clientId) + .putString(CLIENT_SECRET, credentials.clientSecret) + .apply() - // Before we open browser page we save the data. - // Even if we don't open other apps user may go to password manager or somewhere else - // and we will need to pick up the process where we left off. - // Alternatively we could pass it all as part of the intent and receive it back - // but it is a bit of a workaround. - preferences.edit() - .putString(DOMAIN, domain) - .putString(CLIENT_ID, credentials.clientId) - .putString(CLIENT_SECRET, credentials.clientSecret) - .apply() - - redirectUserToAuthorizeAndLogin(domain, credentials.clientId) + redirectUserToAuthorizeAndLogin(domain, credentials.clientId) + }, + { e -> + binding.loginButton.isEnabled = true + binding.domainTextInputLayout.error = + getString(R.string.error_failed_app_registration) + setLoading(false) + Log.e(TAG, Log.getStackTraceString(e)) + return@launch + } + ) } } @@ -217,29 +217,28 @@ class LoginActivity : BaseActivity(), Injectable { setLoading(true) - val accessToken = try { - mastodonApi.fetchOAuthToken( - domain, clientId, clientSecret, oauthRedirectUri, code, - "authorization_code" - ) - } catch (e: Exception) { - setLoading(false) - binding.domainTextInputLayout.error = - getString(R.string.error_retrieving_oauth_token) - Log.e( - TAG, - "%s %s".format(getString(R.string.error_retrieving_oauth_token), e.message), - ) - return - } + mastodonApi.fetchOAuthToken( + domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code" + ).fold( + { accessToken -> + accountManager.addAccount(accessToken.accessToken, domain) - accountManager.addAccount(accessToken.accessToken, domain) - - val intent = Intent(this, MainActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - startActivity(intent) - finish() - overridePendingTransition(R.anim.explode, R.anim.explode) + val intent = Intent(this, MainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + startActivity(intent) + finish() + overridePendingTransition(R.anim.explode, R.anim.explode) + }, + { e -> + setLoading(false) + binding.domainTextInputLayout.error = + getString(R.string.error_retrieving_oauth_token) + Log.e( + TAG, + "%s %s".format(getString(R.string.error_retrieving_oauth_token), e.message), + ) + } + ) } private fun setLoading(loadingState: Boolean) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt index 01f6c3b0e..827b56208 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt @@ -16,6 +16,7 @@ import android.webkit.WebStorage import android.webkit.WebView import android.webkit.WebViewClient import androidx.activity.result.contract.ActivityResultContract +import androidx.core.net.toUri import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.databinding.LoginWebviewBinding @@ -103,8 +104,8 @@ class LoginWebViewActivity : BaseActivity(), Injectable { webView.webViewClient = object : WebViewClient() { override fun onReceivedError( - view: WebView?, - request: WebResourceRequest?, + view: WebView, + request: WebResourceRequest, error: WebResourceError ) { Log.d("LoginWeb", "Failed to load ${data.url}: $error") @@ -115,7 +116,17 @@ class LoginWebViewActivity : BaseActivity(), Injectable { view: WebView, request: WebResourceRequest ): Boolean { - val url = request.url + return shouldOverrideUrlLoading(request.url) + } + + /* overriding this deprecated method is necessary for it to work on api levels < 24 */ + @Suppress("OVERRIDE_DEPRECATION") + override fun shouldOverrideUrlLoading(view: WebView?, urlString: String?): Boolean { + val url = urlString?.toUri() ?: return false + return shouldOverrideUrlLoading(url) + } + + fun shouldOverrideUrlLoading(url: Uri): Boolean { return if (url.scheme == oauthUrl.scheme && url.host == oauthUrl.host) { val error = url.getQueryParameter("error") if (error != null) { @@ -130,6 +141,7 @@ class LoginWebViewActivity : BaseActivity(), Injectable { } } } + webView.setBackgroundColor(Color.TRANSPARENT) if (savedInstanceState == null) { 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 6b9afce1d..83682ab28 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java @@ -16,6 +16,9 @@ package com.keylesspalace.tusky.components.notifications; +import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml; +import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription; + import android.app.NotificationChannel; import android.app.NotificationChannelGroup; import android.app.NotificationManager; @@ -73,8 +76,6 @@ import java.util.concurrent.TimeUnit; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.schedulers.Schedulers; -import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription; - public class NotificationHelper { private static int notificationId = 0; @@ -116,6 +117,7 @@ public class NotificationHelper { public static final String CHANNEL_FAVOURITE = "CHANNEL_FAVOURITE"; public static final String CHANNEL_POLL = "CHANNEL_POLL"; public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS"; + public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP"; /** * WorkManager Tag @@ -340,7 +342,7 @@ public class NotificationHelper { Status status = body.getStatus(); String citedLocalAuthor = status.getAccount().getLocalUsername(); - String citedText = status.getContent().toString(); + String citedText = parseAsMastodonHtml(status.getContent()).toString(); String inReplyToId = status.getId(); Status actionableStatus = status.getActionableStatus(); Status.Visibility replyVisibility = actionableStatus.getVisibility(); @@ -392,6 +394,7 @@ public class NotificationHelper { CHANNEL_FAVOURITE + account.getIdentifier(), CHANNEL_POLL + account.getIdentifier(), CHANNEL_SUBSCRIPTIONS + account.getIdentifier(), + CHANNEL_SIGN_UP + account.getIdentifier(), }; int[] channelNames = { R.string.notification_mention_name, @@ -401,6 +404,7 @@ public class NotificationHelper { R.string.notification_favourite_name, R.string.notification_poll_name, R.string.notification_subscription_name, + R.string.notification_sign_up_name, }; int[] channelDescriptions = { R.string.notification_mention_descriptions, @@ -410,6 +414,7 @@ public class NotificationHelper { R.string.notification_favourite_description, R.string.notification_poll_description, R.string.notification_subscription_description, + R.string.notification_sign_up_description, }; List channels = new ArrayList<>(6); @@ -560,6 +565,8 @@ public class NotificationHelper { return account.getNotificationsFavorited(); case POLL: return account.getNotificationsPolls(); + case SIGN_UP: + return account.getNotificationsSignUps(); default: return false; } @@ -582,6 +589,8 @@ public class NotificationHelper { return CHANNEL_FAVOURITE + account.getIdentifier(); case POLL: return CHANNEL_POLL + account.getIdentifier(); + case SIGN_UP: + return CHANNEL_SIGN_UP + account.getIdentifier(); default: return null; } @@ -663,6 +672,8 @@ public class NotificationHelper { } else { return context.getString(R.string.poll_ended_voted); } + case SIGN_UP: + return String.format(context.getString(R.string.notification_sign_up_format), accountName); } return null; } @@ -671,6 +682,7 @@ public class NotificationHelper { switch (notification.getType()) { case FOLLOW: case FOLLOW_REQUEST: + case SIGN_UP: return "@" + notification.getAccount().getUsername(); case MENTION: case FAVOURITE: @@ -679,13 +691,13 @@ public class NotificationHelper { if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) { return notification.getStatus().getSpoilerText(); } else { - return notification.getStatus().getContent().toString(); + return parseAsMastodonHtml(notification.getStatus().getContent()).toString(); } case POLL: if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) { return notification.getStatus().getSpoilerText(); } else { - StringBuilder builder = new StringBuilder(notification.getStatus().getContent()); + StringBuilder builder = new StringBuilder(parseAsMastodonHtml(notification.getStatus().getContent())); builder.append('\n'); Poll poll = notification.getStatus().getPoll(); List options = poll.getOptions(); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt index 2c565a710..47cb37ae7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt @@ -13,8 +13,8 @@ import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.preference.Preference import androidx.preference.PreferenceManager -import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.SplashActivity import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.databinding.DialogEmojicompatBinding import com.keylesspalace.tusky.databinding.ItemEmojiPrefBinding @@ -216,7 +216,7 @@ class EmojiPreference( .setPositiveButton(R.string.restart) { _, _ -> // Restart the app // From https://stackoverflow.com/a/17166729/5070653 - val launchIntent = Intent(context, MainActivity::class.java) + val launchIntent = Intent(context, SplashActivity::class.java) val mPendingIntent = PendingIntent.getActivity( context, 0x1f973, // This is the codepoint of the party face emoji :D diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt index 4d8ba84f3..82ee0a384 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt @@ -122,6 +122,17 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable { true } } + + switchPreference { + setTitle(R.string.pref_title_notification_filter_sign_ups) + key = PrefKeys.NOTIFICATION_FILTER_SIGN_UPS + isIconSpaceReserved = false + isChecked = activeAccount.notificationsSignUps + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.notificationsSignUps = newValue as Boolean } + true + } + } } preferenceCategory(R.string.pref_title_notification_alerts) { category -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt index f8991282d..9f99da530 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn +import androidx.paging.map import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.MuteEvent @@ -34,11 +35,13 @@ import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.Success +import com.keylesspalace.tusky.util.toViewData import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject @@ -74,6 +77,11 @@ class ReportViewModel @Inject constructor( pagingSourceFactory = { StatusesPagingSource(accountId, mastodonApi) } ).flow } + .map { pagingData -> + /* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData.Concrete + instead of StatusViewState */ + pagingData.map { status -> status.toViewData(false, false, false) } + } .cachedIn(viewModelScope) private val selectedIds = HashSet() @@ -155,7 +163,7 @@ class ReportViewModel @Inject constructor( .observeOn(AndroidSchedulers.mainThread()) .subscribe( { relationship -> - val muting = relationship?.muting == true + val muting = relationship.muting muteStateMutable.value = Success(muting) if (muting) { eventHub.dispatch(MuteEvent(accountId)) @@ -180,7 +188,7 @@ class ReportViewModel @Inject constructor( .observeOn(AndroidSchedulers.mainThread()) .subscribe( { relationship -> - val blocking = relationship?.blocking == true + val blocking = relationship.blocking blockStateMutable.value = Success(blocking) if (blocking) { eventHub.dispatch(BlockEvent(accountId)) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt index df601efe9..3c022d3b8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt @@ -37,6 +37,7 @@ import com.keylesspalace.tusky.util.setClickableMentions import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.shouldTrimStatus import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.toViewData import java.util.Date @@ -45,20 +46,21 @@ class StatusViewHolder( private val statusDisplayOptions: StatusDisplayOptions, private val viewState: StatusViewState, private val adapterHandler: AdapterHandler, - private val getStatusForPosition: (Int) -> Status? + private val getStatusForPosition: (Int) -> StatusViewData.Concrete? ) : RecyclerView.ViewHolder(binding.root) { + private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height) private val statusViewHelper = StatusViewHelper(itemView) private val previewListener = object : StatusViewHelper.MediaPreviewListener { override fun onViewMedia(v: View?, idx: Int) { - status()?.let { status -> - adapterHandler.showMedia(v, status, idx) + viewdata()?.let { viewdata -> + adapterHandler.showMedia(v, viewdata.status, idx) } } override fun onContentHiddenChange(isShowing: Boolean) { - status()?.id?.let { id -> + viewdata()?.id?.let { id -> viewState.setMediaShow(id, isShowing) } } @@ -66,57 +68,57 @@ class StatusViewHolder( init { binding.statusSelection.setOnCheckedChangeListener { _, isChecked -> - status()?.let { status -> - adapterHandler.setStatusChecked(status, isChecked) + viewdata()?.let { viewdata -> + adapterHandler.setStatusChecked(viewdata.status, isChecked) } } binding.statusMediaPreviewContainer.clipToOutline = true } - fun bind(status: Status) { - binding.statusSelection.isChecked = adapterHandler.isStatusChecked(status.id) + fun bind(viewData: StatusViewData.Concrete) { + binding.statusSelection.isChecked = adapterHandler.isStatusChecked(viewData.id) updateTextView() - val sensitive = status.sensitive + val sensitive = viewData.status.sensitive statusViewHelper.setMediasPreview( - statusDisplayOptions, status.attachments, - sensitive, previewListener, viewState.isMediaShow(status.id, status.sensitive), + statusDisplayOptions, viewData.status.attachments, + sensitive, previewListener, viewState.isMediaShow(viewData.id, viewData.status.sensitive), mediaViewHeight ) - statusViewHelper.setupPollReadonly(status.poll.toViewData(), status.emojis, statusDisplayOptions) - setCreatedAt(status.createdAt) + statusViewHelper.setupPollReadonly(viewData.status.poll.toViewData(), viewData.status.emojis, statusDisplayOptions) + setCreatedAt(viewData.status.createdAt) } private fun updateTextView() { - status()?.let { status -> + viewdata()?.let { viewdata -> setupCollapsedState( - shouldTrimStatus(status.content), viewState.isCollapsed(status.id, true), - viewState.isContentShow(status.id, status.sensitive), status.spoilerText + shouldTrimStatus(viewdata.content), viewState.isCollapsed(viewdata.id, true), + viewState.isContentShow(viewdata.id, viewdata.status.sensitive), viewdata.spoilerText ) - if (status.spoilerText.isBlank()) { - setTextVisible(true, status.content, status.mentions, status.tags, status.emojis, adapterHandler, status.quote != null) + if (viewdata.spoilerText.isBlank()) { + setTextVisible(true, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler, viewdata.status.quote != null) binding.statusContentWarningButton.hide() binding.statusContentWarningDescription.hide() } else { - val emojiSpoiler = status.spoilerText.emojify(status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis) + val emojiSpoiler = viewdata.spoilerText.emojify(viewdata.status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis) binding.statusContentWarningDescription.text = emojiSpoiler binding.statusContentWarningDescription.show() binding.statusContentWarningButton.show() - setContentWarningButtonText(viewState.isContentShow(status.id, true)) + setContentWarningButtonText(viewState.isContentShow(viewdata.id, true)) binding.statusContentWarningButton.setOnClickListener { - status()?.let { status -> - val contentShown = viewState.isContentShow(status.id, true) + viewdata()?.let { viewdata -> + val contentShown = viewState.isContentShow(viewdata.id, true) binding.statusContentWarningDescription.invalidate() - viewState.setContentShow(status.id, !contentShown) - setTextVisible(!contentShown, status.content, status.mentions, status.tags, status.emojis, adapterHandler, status.quote != null) + viewState.setContentShow(viewdata.id, !contentShown) + setTextVisible(!contentShown, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler, viewdata.status.quote != null) setContentWarningButtonText(!contentShown) } } - setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.tags, status.emojis, adapterHandler, status.quote != null) + setTextVisible(viewState.isContentShow(viewdata.id, true), viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler, viewdata.status.quote != null) } } } @@ -170,8 +172,8 @@ class StatusViewHolder( /* input filter for TextViews have to be set before text */ if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) { binding.buttonToggleContent.setOnClickListener { - status()?.let { status -> - viewState.setCollapsed(status.id, !collapsed) + viewdata()?.let { viewdata -> + viewState.setCollapsed(viewdata.id, !collapsed) updateTextView() } } @@ -190,5 +192,5 @@ class StatusViewHolder( } } - private fun status() = getStatusForPosition(bindingAdapterPosition) + private fun viewdata() = getStatusForPosition(bindingAdapterPosition) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt index 76ed2ebea..314513eb9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt @@ -22,16 +22,16 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.components.report.model.StatusViewState import com.keylesspalace.tusky.databinding.ItemReportStatusBinding -import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.StatusViewData class StatusesAdapter( private val statusDisplayOptions: StatusDisplayOptions, private val statusViewState: StatusViewState, private val adapterHandler: AdapterHandler -) : PagingDataAdapter(STATUS_COMPARATOR) { +) : PagingDataAdapter(STATUS_COMPARATOR) { - private val statusForPosition: (Int) -> Status? = { position: Int -> + private val statusForPosition: (Int) -> StatusViewData.Concrete? = { position: Int -> if (position != RecyclerView.NO_POSITION) getItem(position) else null } @@ -50,11 +50,11 @@ class StatusesAdapter( } companion object { - val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { - override fun areContentsTheSame(oldItem: Status, newItem: Status): Boolean = + val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areContentsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean = oldItem == newItem - override fun areItemsTheSame(oldItem: Status, newItem: Status): Boolean = + override fun areItemsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean = oldItem.id == newItem.id } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusViewModel.kt index cd3e5ac0c..766ed44ab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusViewModel.kt @@ -25,7 +25,6 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.network.MastodonApi import kotlinx.coroutines.launch -import kotlinx.coroutines.rx3.await import javax.inject.Inject class ScheduledStatusViewModel @Inject constructor( @@ -43,12 +42,14 @@ class ScheduledStatusViewModel @Inject constructor( fun deleteScheduledStatus(status: ScheduledStatus) { viewModelScope.launch { - try { - mastodonApi.deleteScheduledStatus(status.id).await() - pagingSourceFactory.remove(status) - } catch (throwable: Throwable) { - Log.w("ScheduledTootViewModel", "Error deleting scheduled status", throwable) - } + mastodonApi.deleteScheduledStatus(status.id).fold( + { + pagingSourceFactory.remove(status) + }, + { throwable -> + Log.w("ScheduledTootViewModel", "Error deleting scheduled status", throwable) + } + ) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt index b62081760..79512e07b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt @@ -15,9 +15,6 @@ package com.keylesspalace.tusky.components.timeline -import android.text.SpannedString -import androidx.core.text.parseAsHtml -import androidx.core.text.toHtml import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.keylesspalace.tusky.db.TimelineAccountEntity @@ -29,8 +26,6 @@ import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.TimelineAccount -import com.keylesspalace.tusky.util.shouldTrimStatus -import com.keylesspalace.tusky.util.trimTrailingWhitespace import com.keylesspalace.tusky.viewdata.StatusViewData import java.util.Date @@ -119,7 +114,7 @@ fun Status.toEntity( authorServerId = actionableStatus.account.id, inReplyToId = actionableStatus.inReplyToId, inReplyToAccountId = actionableStatus.inReplyToAccountId, - content = actionableStatus.content.toHtml(), + content = actionableStatus.content, createdAt = actionableStatus.createdAt.time, emojis = actionableStatus.emojis.let(gson::toJson), reblogsCount = actionableStatus.reblogsCount, @@ -165,8 +160,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { inReplyToId = status.inReplyToId, inReplyToAccountId = status.inReplyToAccountId, reblog = null, - content = status.content?.parseAsHtml()?.trimTrailingWhitespace() - ?: SpannedString(""), + content = status.content.orEmpty(), createdAt = Date(status.createdAt), emojis = emojis, reblogsCount = status.reblogsCount, @@ -196,7 +190,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { inReplyToId = null, inReplyToAccountId = null, reblog = reblog, - content = SpannedString(""), + content = "", createdAt = Date(status.createdAt), // lie but whatever? emojis = listOf(), reblogsCount = 0, @@ -225,8 +219,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { inReplyToId = status.inReplyToId, inReplyToAccountId = status.inReplyToAccountId, reblog = null, - content = status.content?.parseAsHtml()?.trimTrailingWhitespace() - ?: SpannedString(""), + content = status.content.orEmpty(), createdAt = Date(status.createdAt), emojis = emojis, reblogsCount = status.reblogsCount, @@ -252,7 +245,6 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { status = status, isExpanded = this.status.expanded, isShowingContent = this.status.contentShowing, - isCollapsible = shouldTrimStatus(status.content), isCollapsed = this.status.contentCollapsed ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index f293ef0f2..b945debeb 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 @@ -43,7 +43,10 @@ import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.await @@ -82,15 +85,13 @@ class CachedTimelineViewModel @Inject constructor( } ).flow .map { pagingData -> - pagingData.map { timelineStatus -> + pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus -> timelineStatus.toViewData(gson) - } - } - .map { pagingData -> - pagingData.filter { statusViewData -> + }.filter(Dispatchers.Default.asExecutor()) { statusViewData -> !shouldFilterStatus(statusViewData) } } + .flowOn(Dispatchers.Default) .cachedIn(viewModelScope) init { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 64236bfa0..0685a92eb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -40,6 +40,9 @@ import com.keylesspalace.tusky.util.isLessThan import com.keylesspalace.tusky.util.isLessThanOrEqual import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.await @@ -81,10 +84,11 @@ class NetworkTimelineViewModel @Inject constructor( remoteMediator = NetworkTimelineRemoteMediator(accountManager, this) ).flow .map { pagingData -> - pagingData.filter { statusViewData -> + pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData -> !shouldFilterStatus(statusViewData) } } + .flowOn(Dispatchers.Default) .cachedIn(viewModelScope) override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt index 0c25cbbc9..5da91e201 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -50,6 +50,7 @@ data class AccountEntity( var notificationsFavorited: Boolean = true, var notificationsPolls: Boolean = true, var notificationsSubscriptions: Boolean = true, + var notificationsSignUps: Boolean = true, var notificationSound: Boolean = true, var notificationVibration: Boolean = true, var notificationLight: Boolean = true, diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index 159a6f529..c541958ae 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -31,7 +31,7 @@ import java.io.File; */ @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, ConversationEntity.class - }, version = 31) + }, version = 33) public abstract class AppDatabase extends RoomDatabase { public abstract AccountDao accountDao(); @@ -483,4 +483,48 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("DELETE FROM `TimelineStatusEntity`"); } }; + + public static final Migration MIGRATION_31_32 = new Migration(31, 32) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsSignUps` INTEGER NOT NULL DEFAULT 1"); + } + }; + + public static final Migration MIGRATION_32_33 = new Migration(32, 33) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + + // ConversationEntity lost the s_collapsible column + // since SQLite does not support removing columns and it is just a cache table, we recreate the whole table. + database.execSQL("DROP TABLE `ConversationEntity`"); + database.execSQL("CREATE TABLE IF NOT EXISTS `ConversationEntity` (" + + "`accountId` INTEGER NOT NULL," + + "`id` TEXT NOT NULL," + + "`accounts` TEXT NOT NULL," + + "`unread` INTEGER NOT NULL," + + "`s_id` TEXT NOT NULL," + + "`s_url` TEXT," + + "`s_inReplyToId` TEXT," + + "`s_inReplyToAccountId` TEXT," + + "`s_account` TEXT NOT NULL," + + "`s_content` TEXT NOT NULL," + + "`s_createdAt` INTEGER NOT NULL," + + "`s_emojis` TEXT NOT NULL," + + "`s_favouritesCount` INTEGER NOT NULL," + + "`s_favourited` INTEGER NOT NULL," + + "`s_bookmarked` INTEGER NOT NULL," + + "`s_sensitive` INTEGER NOT NULL," + + "`s_spoilerText` TEXT NOT NULL," + + "`s_attachments` TEXT NOT NULL," + + "`s_mentions` TEXT NOT NULL," + + "`s_tags` TEXT," + + "`s_showingHiddenContent` INTEGER NOT NULL," + + "`s_expanded` INTEGER NOT NULL," + + "`s_collapsed` INTEGER NOT NULL," + + "`s_muted` INTEGER NOT NULL," + + "`s_poll` TEXT," + + "PRIMARY KEY(`id`, `accountId`))"); + } + }; } 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 393a23925..fe093bd0c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt @@ -17,7 +17,6 @@ package com.keylesspalace.tusky.db import androidx.paging.PagingSource import androidx.room.Dao -import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query @@ -31,8 +30,8 @@ interface ConversationsDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(conversation: ConversationEntity): Long - @Delete - suspend fun delete(conversation: ConversationEntity): Int + @Query("DELETE FROM ConversationEntity WHERE id = :id AND accountId = :accountId") + suspend fun delete(id: String, accountId: Long): Int @Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC") fun conversationsForAccount(accountId: Long): PagingSource diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt index ef822efe9..5efb7e628 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -15,9 +15,6 @@ package com.keylesspalace.tusky.db -import android.text.Spanned -import androidx.core.text.parseAsHtml -import androidx.core.text.toHtml import androidx.room.ProvidedTypeConverter import androidx.room.TypeConverter import com.google.gson.Gson @@ -32,10 +29,8 @@ import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.util.trimTrailingWhitespace import java.net.URLDecoder import java.net.URLEncoder -import java.util.ArrayList import java.util.Date import javax.inject.Inject import javax.inject.Singleton @@ -144,22 +139,6 @@ class Converters @Inject constructor ( return Date(date) } - @TypeConverter - fun spannedToString(spanned: Spanned?): String? { - if (spanned == null) { - return null - } - return spanned.toHtml() - } - - @TypeConverter - fun stringToSpanned(spannedString: String?): Spanned? { - if (spannedString == null) { - return null - } - return spannedString.parseAsHtml().trimTrailingWhitespace() - } - @TypeConverter fun pollToJson(poll: Poll?): String? { return gson.toJson(poll) diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt index b3ac85daf..abd777619 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -23,6 +23,7 @@ import com.keylesspalace.tusky.FiltersActivity import com.keylesspalace.tusky.LicenseActivity import com.keylesspalace.tusky.ListsActivity import com.keylesspalace.tusky.MainActivity +import com.keylesspalace.tusky.SplashActivity import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.ViewMediaActivity @@ -118,6 +119,9 @@ abstract class ActivitiesModule { @ContributesAndroidInjector abstract fun contributesDraftActivity(): DraftsActivity + @ContributesAndroidInjector + abstract fun contributesSplashActivity(): SplashActivity + @ContributesAndroidInjector abstract fun contributesAccessTokenLoginActivity(): AccessTokenLoginActivity } 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 4695f63b8..326428fa8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -68,7 +68,8 @@ class AppModule { AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25, AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")), AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29, - AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31 + AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32, + AppDatabase.MIGRATION_32_33 ) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index 0488c836d..0c2458208 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -18,12 +18,10 @@ package com.keylesspalace.tusky.di import android.content.Context import android.content.SharedPreferences import android.os.Build -import android.text.Spanned +import at.connyduck.calladapter.kotlinresult.KotlinResultCallAdapterFactory import com.google.gson.Gson -import com.google.gson.GsonBuilder import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.json.SpannedTypeAdapter import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.NotestockApi @@ -53,11 +51,7 @@ class NetworkModule { @Provides @Singleton - fun providesGson(): Gson { - return GsonBuilder() - .registerTypeAdapter(Spanned::class.java, SpannedTypeAdapter()) - .create() - } + fun providesGson() = Gson() @Provides @Singleton @@ -114,6 +108,7 @@ class NetworkModule { .client(httpClient) .addConverterFactory(GsonConverterFactory.create(gson)) .addCallAdapterFactory(RxJava3CallAdapterFactory.create()) + .addCallAdapterFactory(KotlinResultCallAdapterFactory.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 ab20a4e64..db01a5f9b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt @@ -15,7 +15,6 @@ package com.keylesspalace.tusky.entity -import android.text.Spanned import com.google.gson.annotations.SerializedName import java.util.Date @@ -24,7 +23,7 @@ data class Account( @SerializedName("username") val localUsername: String, @SerializedName("acct", alternate = ["subject"]) val username: String, @SerializedName("display_name") private val displayName: String?, // should never be null per Api definition, but some servers break the contract - val note: Spanned, + val note: String, val url: String, val avatar: String, val header: String, @@ -54,56 +53,6 @@ data class Account( get() = displayName.orEmpty() fun isRemote(): Boolean = this.username != this.localUsername - - /** - * overriding equals & hashcode because Spanned does not always compare correctly otherwise - */ - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - other as Account - - if (id != other.id) return false - if (localUsername != other.localUsername) return false - if (username != other.username) return false - if (displayName != other.displayName) return false - if (note.toString() != other.note.toString()) return false - if (url != other.url) return false - if (avatar != other.avatar) return false - if (header != other.header) return false - if (locked != other.locked) return false - if (followersCount != other.followersCount) return false - if (followingCount != other.followingCount) return false - if (statusesCount != other.statusesCount) return false - if (source != other.source) return false - if (bot != other.bot) return false - if (emojis != other.emojis) return false - if (fields != other.fields) return false - if (moved != other.moved) return false - - return true - } - - override fun hashCode(): Int { - var result = id.hashCode() - result = 31 * result + localUsername.hashCode() - result = 31 * result + username.hashCode() - result = 31 * result + (displayName?.hashCode() ?: 0) - result = 31 * result + note.toString().hashCode() - result = 31 * result + url.hashCode() - result = 31 * result + avatar.hashCode() - result = 31 * result + header.hashCode() - result = 31 * result + locked.hashCode() - result = 31 * result + followersCount - result = 31 * result + followingCount - result = 31 * result + statusesCount - result = 31 * result + (source?.hashCode() ?: 0) - result = 31 * result + bot.hashCode() - result = 31 * result + (emojis?.hashCode() ?: 0) - result = 31 * result + (fields?.hashCode() ?: 0) - result = 31 * result + (moved?.hashCode() ?: 0) - return result - } } data class AccountSource( @@ -115,7 +64,7 @@ data class AccountSource( data class Field( val name: String, - val value: Spanned, + val value: String, @SerializedName("verified_at") val verifiedAt: Date? ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt index 400e9764d..00d5659d5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt @@ -15,13 +15,12 @@ package com.keylesspalace.tusky.entity -import android.text.Spanned import com.google.gson.annotations.SerializedName import java.util.Date data class Announcement( val id: String, - val content: Spanned, + val content: String, @SerializedName("starts_at") val startsAt: Date?, @SerializedName("ends_at") val endsAt: Date?, @SerializedName("all_day") val allDay: Boolean, diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt index 52011f3d1..29fe7f8ee 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt @@ -15,13 +15,12 @@ package com.keylesspalace.tusky.entity -import android.text.Spanned import com.google.gson.annotations.SerializedName data class Card( val url: String, - val title: Spanned, - val description: Spanned, + val title: String, + val description: String, @SerializedName("author_name") val authorName: String, val image: String, val type: String, @@ -31,9 +30,7 @@ data class Card( val embed_url: String? ) { - override fun hashCode(): Int { - return url.hashCode() - } + override fun hashCode() = url.hashCode() override fun equals(other: Any?): Boolean { if (other !is Card) { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt index ae2d74a90..ddcf5e618 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -37,7 +37,9 @@ data class Notification( FOLLOW("follow"), FOLLOW_REQUEST("follow_request"), POLL("poll"), - STATUS("status"); + STATUS("status"), + SIGN_UP("admin.sign_up"), + ; companion object { @@ -49,7 +51,7 @@ data class Notification( } return UNKNOWN } - val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS) + val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP) } override fun toString(): String { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index d69e5d807..971d53139 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -16,10 +16,9 @@ package com.keylesspalace.tusky.entity import android.text.SpannableStringBuilder -import android.text.Spanned import android.text.style.URLSpan import com.google.gson.annotations.SerializedName -import java.util.ArrayList +import com.keylesspalace.tusky.util.parseAsMastodonHtml import java.util.Date data class Status( @@ -29,7 +28,7 @@ data class Status( @SerializedName("in_reply_to_id") var inReplyToId: String?, @SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?, val reblog: Status?, - val content: Spanned, + val content: String, @SerializedName("created_at", alternate = ["published"]) val createdAt: Date, val emojis: List, @SerializedName("reblogs_count") val reblogsCount: Int, @@ -143,8 +142,9 @@ data class Status( } private fun getEditableText(): String { - val builder = SpannableStringBuilder(content) - for (span in content.getSpans(0, content.length, URLSpan::class.java)) { + val contentSpanned = content.parseAsMastodonHtml() + val builder = SpannableStringBuilder(content.parseAsMastodonHtml()) + for (span in contentSpanned.getSpans(0, content.length, URLSpan::class.java)) { val url = span.url for ((_, url1, username) in mentions) { if (url == url1) { @@ -158,71 +158,6 @@ data class Status( return builder.toString() } - /** - * overriding equals & hashcode because Spanned does not always compare correctly otherwise - */ - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - other as Status - - if (id != other.id) return false - if (url != other.url) return false - if (account != other.account) return false - if (inReplyToId != other.inReplyToId) return false - if (inReplyToAccountId != other.inReplyToAccountId) return false - if (reblog != other.reblog) return false - if (content.toString() != other.content.toString()) return false - if (createdAt != other.createdAt) return false - if (emojis != other.emojis) return false - if (reblogsCount != other.reblogsCount) return false - if (favouritesCount != other.favouritesCount) return false - if (reblogged != other.reblogged) return false - if (favourited != other.favourited) return false - if (bookmarked != other.bookmarked) return false - if (sensitive != other.sensitive) return false - if (spoilerText != other.spoilerText) return false - if (visibility != other.visibility) return false - if (attachments != other.attachments) return false - if (mentions != other.mentions) return false - if (tags != other.tags) return false - if (application != other.application) return false - if (pinned != other.pinned) return false - if (muted != other.muted) return false - if (poll != other.poll) return false - if (card != other.card) return false - return true - } - - override fun hashCode(): Int { - var result = id.hashCode() - result = 31 * result + (url?.hashCode() ?: 0) - result = 31 * result + account.hashCode() - result = 31 * result + (inReplyToId?.hashCode() ?: 0) - result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0) - result = 31 * result + (reblog?.hashCode() ?: 0) - result = 31 * result + content.toString().hashCode() - result = 31 * result + createdAt.hashCode() - result = 31 * result + emojis.hashCode() - result = 31 * result + reblogsCount - result = 31 * result + favouritesCount - result = 31 * result + reblogged.hashCode() - result = 31 * result + favourited.hashCode() - result = 31 * result + bookmarked.hashCode() - result = 31 * result + sensitive.hashCode() - result = 31 * result + spoilerText.hashCode() - result = 31 * result + visibility.hashCode() - result = 31 * result + attachments.hashCode() - result = 31 * result + mentions.hashCode() - result = 31 * result + (tags?.hashCode() ?: 0) - result = 31 * result + (application?.hashCode() ?: 0) - result = 31 * result + (pinned?.hashCode() ?: 0) - result = 31 * result + (muted?.hashCode() ?: 0) - result = 31 * result + (poll?.hashCode() ?: 0) - result = 31 * result + (card?.hashCode() ?: 0) - return result - } - data class Mention( val id: String, val url: String, diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index b510597cd..16ca79d44 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -15,6 +15,10 @@ package com.keylesspalace.tusky.fragment; +import static com.keylesspalace.tusky.util.StringUtils.isLessThan; +import static autodispose2.AutoDispose.autoDisposable; +import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from; + import android.app.Activity; import android.content.Context; import android.content.DialogInterface; @@ -114,10 +118,6 @@ import kotlin.Unit; import kotlin.collections.CollectionsKt; import kotlin.jvm.functions.Function1; -import static autodispose2.AutoDispose.autoDisposable; -import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from; -import static com.keylesspalace.tusky.util.StringUtils.isLessThan; - public class NotificationsFragment extends SFragment implements SwipeRefreshLayout.OnRefreshListener, StatusActionListener, @@ -716,6 +716,8 @@ public class NotificationsFragment extends SFragment implements return getString(R.string.notification_poll_name); case STATUS: return getString(R.string.notification_subscription_name); + case SIGN_UP: + return getString(R.string.notification_sign_up_name); default: return "Unknown"; } diff --git a/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.kt deleted file mode 100644 index 94a05065b..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* Copyright 2020 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.json - -import android.text.Spanned -import android.text.SpannedString -import androidx.core.text.HtmlCompat -import androidx.core.text.parseAsHtml -import androidx.core.text.toHtml -import com.google.gson.JsonDeserializationContext -import com.google.gson.JsonDeserializer -import com.google.gson.JsonElement -import com.google.gson.JsonParseException -import com.google.gson.JsonPrimitive -import com.google.gson.JsonSerializationContext -import com.google.gson.JsonSerializer -import com.keylesspalace.tusky.util.trimTrailingWhitespace -import org.jsoup.Jsoup -import java.lang.reflect.Type - -class SpannedTypeAdapter : JsonDeserializer, JsonSerializer { - @Throws(JsonParseException::class) - override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Spanned { - return json.asString - /* Mastodon uses 'white-space: pre-wrap;' so spaces are displayed as returned by the Api. - * We can't use CSS so we replace spaces with non-breaking-spaces to emulate the behavior. - */ - ?.replace("
", "
 ") - ?.replace("
", "
 ") - ?.replace("
", "
 ") - ?.replace(" ", "  ") - ?.let { html -> - Jsoup.parse(html) - .apply { - select(".quote-inline").forEach { it.remove() } - } - .html() - } - ?.parseAsHtml() - /* Html.fromHtml returns trailing whitespace if the html ends in a

tag, which - * most status contents do, so it should be trimmed. */ - ?.trimTrailingWhitespace() - ?: SpannedString("") - } - - override fun serialize(src: Spanned?, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { - return JsonPrimitive(src!!.toHtml(HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL)) - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 3ee8d4cec..111cad56c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -68,7 +68,7 @@ import retrofit2.http.Query interface MastodonApi { companion object { - const val ENDPOINT_AUTHORIZE = "/oauth/authorize" + const val ENDPOINT_AUTHORIZE = "oauth/authorize" const val DOMAIN_HEADER = "domain" const val PLACEHOLDER_DOMAIN = "dummy.placeholder" } @@ -80,7 +80,7 @@ interface MastodonApi { fun getCustomEmojis(): Single> @GET("api/v1/instance") - fun getInstance(): Single + suspend fun getInstance(): Result @GET("api/v1/filters") fun getFilters(): Single> @@ -249,12 +249,12 @@ interface MastodonApi { ): Single> @DELETE("api/v1/scheduled_statuses/{id}") - fun deleteScheduledStatus( + suspend fun deleteScheduledStatus( @Path("id") scheduledStatusId: String - ): Single + ): Result @GET("api/v1/accounts/verify_credentials") - fun accountVerifyCredentials(): Single + suspend fun accountVerifyCredentials(): Result @FormUrlEncoded @PATCH("api/v1/accounts/update_credentials") @@ -265,7 +265,7 @@ interface MastodonApi { @Multipart @PATCH("api/v1/accounts/update_credentials") - fun accountUpdateCredentials( + suspend fun accountUpdateCredentials( @Part(value = "display_name") displayName: RequestBody?, @Part(value = "note") note: RequestBody?, @Part(value = "locked") locked: RequestBody?, @@ -279,7 +279,7 @@ interface MastodonApi { @Part(value = "fields_attributes[2][value]") fieldValue2: RequestBody?, @Part(value = "fields_attributes[3][name]") fieldName3: RequestBody?, @Part(value = "fields_attributes[3][value]") fieldValue3: RequestBody? - ): Call + ): Result @GET("api/v1/accounts/search") fun searchAccounts( @@ -447,7 +447,7 @@ interface MastodonApi { @Field("redirect_uris") redirectUris: String, @Field("scopes") scopes: String, @Field("website") website: String - ): AppCredentials + ): Result @FormUrlEncoded @POST("oauth/token") @@ -458,7 +458,7 @@ interface MastodonApi { @Field("redirect_uri") redirectUri: String, @Field("code") code: String, @Field("grant_type") grantType: String - ): AccessToken + ): Result @FormUrlEncoded @POST("api/v1/lists") 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 c0ef7b2f5..db2bf9cf5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -69,6 +69,7 @@ object PrefKeys { const val NOTIFICATION_FILTER_FOLLOW_REQUESTS = "notificationFilterFollowRequests" const val NOTIFICATIONS_FILTER_FOLLOWS = "notificationFilterFollows" const val NOTIFICATION_FILTER_SUBSCRIPTIONS = "notificationFilterSubscriptions" + const val NOTIFICATION_FILTER_SIGN_UPS = "notificationFilterSignUps" const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies" const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts" diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt new file mode 100644 index 000000000..12098a6ee --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt @@ -0,0 +1,70 @@ +/* Copyright 2022 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +@file:JvmName("StatusParsingHelper") + +package com.keylesspalace.tusky.util + +import android.text.SpannableStringBuilder +import android.text.Spanned +import androidx.core.text.parseAsHtml +import org.jsoup.Jsoup.parse + +/** + * parse a String containing html from the Mastodon api to Spanned + */ +fun String.parseAsMastodonHtml(): Spanned { + return this.replace("
", "
 ") + .replace("
", "
 ") + .replace("
", "
 ") + .replace(" ", "  ") + .let { parse(it) } + .apply { + select(".quote-inline").forEach { it.remove() } + } + .html() + .parseAsHtml() + /* Html.fromHtml returns trailing whitespace if the html ends in a

tag, which + * most status contents do, so it should be trimmed. */ + .trimTrailingWhitespace() +} + +fun replaceCrashingCharacters(content: Spanned): Spanned { + return replaceCrashingCharacters(content as CharSequence) as Spanned +} + +fun replaceCrashingCharacters(content: CharSequence): CharSequence? { + var replacing = false + var builder: SpannableStringBuilder? = null + val length = content.length + for (index in 0 until length) { + val character = content[index] + + // If there are more than one or two, switch to a map + if (character == SOFT_HYPHEN) { + if (!replacing) { + replacing = true + builder = SpannableStringBuilder(content, 0, index) + } + builder!!.append(ASCII_HYPHEN) + } else if (replacing) { + builder!!.append(character) + } + } + return if (replacing) builder else content +} + +private const val SOFT_HYPHEN = '\u00ad' +private const val ASCII_HYPHEN = '-' diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt index 52d9713f4..fef9c0bb8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -27,12 +27,9 @@ fun Status.toViewData( isExpanded: Boolean, isCollapsed: Boolean ): StatusViewData.Concrete { - val visibleStatus = this.reblog ?: this - return StatusViewData.Concrete( status = this, isShowingContent = isShowingContent, - isCollapsible = shouldTrimStatus(visibleStatus.content), isCollapsed = isCollapsed, isExpanded = isExpanded, ) diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index d8f271578..be94369c5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -15,9 +15,11 @@ package com.keylesspalace.tusky.viewdata import android.os.Build -import android.text.SpannableStringBuilder import android.text.Spanned import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.util.parseAsMastodonHtml +import com.keylesspalace.tusky.util.replaceCrashingCharacters +import com.keylesspalace.tusky.util.shouldTrimStatus /** * Created by charlag on 11/07/2017. @@ -32,13 +34,6 @@ sealed class StatusViewData { val status: Status, val isExpanded: Boolean, val isShowingContent: Boolean, - /** - * Specifies whether the content of this post is allowed to be collapsed or if it should show - * all content regardless. - * - * @return Whether the post is collapsible or never collapsed. - */ - val isCollapsible: Boolean, /** * Specifies whether the content of this post is currently limited in visibility to the first * 500 characters or not. @@ -51,6 +46,14 @@ sealed class StatusViewData { override val id: String get() = status.id + /** + * Specifies whether the content of this post is allowed to be collapsed or if it should show + * all content regardless. + * + * @return Whether the post is collapsible or never collapsed. + */ + val isCollapsible: Boolean + val content: Spanned val spoilerText: String val username: String @@ -71,48 +74,23 @@ sealed class StatusViewData { val rebloggingStatus: Status? get() = if (status.reblog != null) status else null + val quoteViewData = + status.quote?.let { Concrete(it, isExpanded, isShowingContent, isCollapsed) } + init { if (Build.VERSION.SDK_INT == 23) { // https://github.com/tuskyapp/Tusky/issues/563 - this.content = replaceCrashingCharacters(status.actionableStatus.content) + this.content = replaceCrashingCharacters(status.actionableStatus.content.parseAsMastodonHtml()) this.spoilerText = replaceCrashingCharacters(status.actionableStatus.spoilerText).toString() this.username = replaceCrashingCharacters(status.actionableStatus.account.username).toString() } else { - this.content = status.actionableStatus.content + this.content = status.actionableStatus.content.parseAsMastodonHtml() this.spoilerText = status.actionableStatus.spoilerText this.username = status.actionableStatus.account.username } - } - - companion object { - private const val SOFT_HYPHEN = '\u00ad' - private const val ASCII_HYPHEN = '-' - fun replaceCrashingCharacters(content: Spanned): Spanned { - return replaceCrashingCharacters(content as CharSequence) as Spanned - } - - fun replaceCrashingCharacters(content: CharSequence): CharSequence? { - var replacing = false - var builder: SpannableStringBuilder? = null - val length = content.length - for (index in 0 until length) { - val character = content[index] - - // If there are more than one or two, switch to a map - if (character == SOFT_HYPHEN) { - if (!replacing) { - replacing = true - builder = SpannableStringBuilder(content, 0, index) - } - builder!!.append(ASCII_HYPHEN) - } else if (replacing) { - builder!!.append(character) - } - } - return if (replacing) builder else content - } + this.isCollapsible = shouldTrimStatus(this.content) } /** Helper for Java */ diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt index c3c568db3..5849582ab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -20,6 +20,7 @@ import android.net.Uri import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.entity.Account @@ -31,8 +32,7 @@ import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.randomAlphanumericString -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.kotlin.addTo +import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody @@ -40,9 +40,7 @@ import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONException import org.json.JSONObject -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response +import retrofit2.HttpException import java.io.File import javax.inject.Inject @@ -63,24 +61,20 @@ class EditProfileViewModel @Inject constructor( private var oldProfileData: Account? = null - private val disposables = CompositeDisposable() - - fun obtainProfile() { + fun obtainProfile() = viewModelScope.launch { if (profileData.value == null || profileData.value is Error) { profileData.postValue(Loading()) - mastodonApi.accountVerifyCredentials() - .subscribe( - { profile -> - oldProfileData = profile - profileData.postValue(Success(profile)) - }, - { - profileData.postValue(Error()) - } - ) - .addTo(disposables) + mastodonApi.accountVerifyCredentials().fold( + { profile -> + oldProfileData = profile + profileData.postValue(Success(profile)) + }, + { + profileData.postValue(Error()) + } + ) } } @@ -151,34 +145,34 @@ class EditProfileViewModel @Inject constructor( return } - mastodonApi.accountUpdateCredentials( - displayName, note, locked, avatar, header, - field1?.first, field1?.second, field2?.first, field2?.second, field3?.first, field3?.second, field4?.first, field4?.second - ).enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - val newProfileData = response.body() - if (!response.isSuccessful || newProfileData == null) { - val errorResponse = response.errorBody()?.string() - val errorMsg = if (!errorResponse.isNullOrBlank()) { - try { - JSONObject(errorResponse).optString("error", null) - } catch (e: JSONException) { + viewModelScope.launch { + mastodonApi.accountUpdateCredentials( + displayName, note, locked, avatar, header, + field1?.first, field1?.second, field2?.first, field2?.second, field3?.first, field3?.second, field4?.first, field4?.second + ).fold( + { newProfileData -> + saveData.postValue(Success()) + eventHub.dispatch(ProfileEditedEvent(newProfileData)) + }, + { throwable -> + if (throwable is HttpException) { + val errorResponse = throwable.response()?.errorBody()?.string() + val errorMsg = if (!errorResponse.isNullOrBlank()) { + try { + JSONObject(errorResponse).optString("error", "") + } catch (e: JSONException) { + null + } + } else { null } + saveData.postValue(Error(errorMessage = errorMsg)) } else { - null + saveData.postValue(Error()) } - saveData.postValue(Error(errorMessage = errorMsg)) - return } - saveData.postValue(Success()) - eventHub.dispatch(ProfileEditedEvent(newProfileData)) - } - - override fun onFailure(call: Call, t: Throwable) { - saveData.postValue(Error()) - } - }) + ) + } } // cache activity state for rotation change @@ -208,15 +202,11 @@ class EditProfileViewModel @Inject constructor( return File(application.cacheDir, filename) } - override fun onCleared() { - disposables.dispose() - } - - fun obtainInstance() { + fun obtainInstance() = viewModelScope.launch { if (instanceData.value == null || instanceData.value is Error) { instanceData.postValue(Loading()) - mastodonApi.getInstance().subscribe( + mastodonApi.getInstance().fold( { instance -> instanceData.postValue(Success(instance)) }, @@ -224,7 +214,6 @@ class EditProfileViewModel @Inject constructor( instanceData.postValue(Error()) } ) - .addTo(disposables) } } } diff --git a/app/src/main/java/net/accelf/yuito/FooterDrawerItem.kt b/app/src/main/java/net/accelf/yuito/FooterDrawerItem.kt index 4dfb713a8..5e919cd53 100644 --- a/app/src/main/java/net/accelf/yuito/FooterDrawerItem.kt +++ b/app/src/main/java/net/accelf/yuito/FooterDrawerItem.kt @@ -4,7 +4,6 @@ import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import autodispose2.SingleSubscribeProxy import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemDrawerFooterBinding import com.keylesspalace.tusky.entity.Instance @@ -35,14 +34,13 @@ class FooterDrawerItem : AbstractDrawerItem = throw UnsupportedOperationException() - fun setSubscribeProxy(subscribeProxy: SingleSubscribeProxy) { - subscribeProxy.subscribe( - { instance -> - binding.instanceData.text = String.format("%s\n%s\n%s", instance.title, instance.uri, instance.version) - }, - { - binding.instanceData.text = binding.root.context.getString(R.string.instance_data_failed) - } - ) + fun setInstance(instance: Result) { + instance + .onSuccess { + binding.instanceData.text = String.format("%s\n%s\n%s", it.title, it.uri, it.version) + } + .onFailure { + binding.instanceData.text = binding.root.context.getString(R.string.instance_data_failed) + } } } diff --git a/app/src/main/java/net/accelf/yuito/QuoteInlineHelper.java b/app/src/main/java/net/accelf/yuito/QuoteInlineHelper.java deleted file mode 100644 index 8b5f076fc..000000000 --- a/app/src/main/java/net/accelf/yuito/QuoteInlineHelper.java +++ /dev/null @@ -1,150 +0,0 @@ -package net.accelf.yuito; - -import android.content.Context; -import android.text.Spanned; -import android.view.View; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.Px; - -import com.google.android.material.button.MaterialButton; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.entity.Emoji; -import com.keylesspalace.tusky.entity.HashTag; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.entity.TimelineAccount; -import com.keylesspalace.tusky.interfaces.LinkListener; -import com.keylesspalace.tusky.util.CustomEmojiHelper; -import com.keylesspalace.tusky.util.ImageLoadingHelper; -import com.keylesspalace.tusky.util.LinkHelper; -import com.keylesspalace.tusky.util.StatusDisplayOptions; - -import java.util.List; - -public class QuoteInlineHelper { - private final Status quoteStatus; - - private final View quoteContainer; - private final ImageView quoteAvatar; - private final TextView quoteDisplayName; - private final TextView quoteUsername; - private final TextView quoteContentWarningDescription; - private final MaterialButton quoteContentWarningButton; - private final TextView quoteContent; - private final TextView quoteMedia; - - private final LinkListener listener; - @Px - private final int avatarRadius24dp; - private final StatusDisplayOptions statusDisplayOptions; - - public QuoteInlineHelper(Status status, View container, LinkListener listener, - @Px int avatarRadius24dp, StatusDisplayOptions statusDisplayOptions) { - quoteStatus = status; - quoteContainer = container; - quoteAvatar = container.findViewById(R.id.status_quote_inline_avatar); - quoteDisplayName = container.findViewById(R.id.status_quote_inline_display_name); - quoteUsername = container.findViewById(R.id.status_quote_inline_username); - quoteContentWarningDescription = container.findViewById(R.id.status_quote_inline_content_warning_description); - quoteContentWarningButton = container.findViewById(R.id.status_quote_inline_content_warning_button); - quoteContent = container.findViewById(R.id.status_quote_inline_content); - quoteMedia = container.findViewById(R.id.status_quote_inline_media); - this.listener = listener; - this.avatarRadius24dp = avatarRadius24dp; - this.statusDisplayOptions = statusDisplayOptions; - } - - private void setDisplayName(String name, List customEmojis) { - CharSequence emojifiedName = CustomEmojiHelper.emojify(name, customEmojis, quoteDisplayName, statusDisplayOptions.animateEmojis()); - quoteDisplayName.setText(emojifiedName); - } - - private void setUsername(String name) { - Context context = quoteUsername.getContext(); - String format = context.getString(R.string.post_username_format); - String usernameText = String.format(format, name); - quoteUsername.setText(usernameText); - } - - private void setContent( - Spanned content, - List mentions, - List tags, - List emojis, - LinkListener listener - ) { - Spanned singleLineText = SpannedTextHelper.replaceSpanned(content); - CharSequence emojifiedText = CustomEmojiHelper.emojify(singleLineText, emojis, quoteContent, statusDisplayOptions.animateEmojis()); - LinkHelper.setClickableText(quoteContent, emojifiedText, mentions, tags, listener); - } - - private void setAvatar(String url, @Px int avatarRadius24dp, StatusDisplayOptions statusDisplayOptions) { - ImageLoadingHelper.loadAvatar(url, quoteAvatar, avatarRadius24dp, statusDisplayOptions.animateAvatars()); - } - - private void setSpoilerText(String spoilerText, List emojis) { - CharSequence emojiSpoiler = - CustomEmojiHelper.emojify(spoilerText, emojis, quoteContentWarningDescription, statusDisplayOptions.animateEmojis()); - quoteContentWarningDescription.setText(emojiSpoiler); - quoteContentWarningDescription.setVisibility(View.VISIBLE); - quoteContentWarningButton.setVisibility(View.VISIBLE); - quoteContentWarningButton.setOnClickListener(v - -> setContentVisibility(!(quoteContent.getVisibility() == View.VISIBLE))); - setContentVisibility(false); - } - - private void setContentVisibility(boolean show) { - if (show) { - quoteContent.setVisibility(View.VISIBLE); - quoteContentWarningButton.setText(R.string.post_content_warning_show_less); - } else { - quoteContent.setVisibility(View.GONE); - quoteContentWarningButton.setText(R.string.post_content_warning_show_more); - } - } - - private void hideSpoilerText() { - quoteContentWarningDescription.setVisibility(View.GONE); - quoteContentWarningButton.setVisibility(View.GONE); - quoteContent.setVisibility(View.VISIBLE); - } - - private void setOnClickListener(String accountId, String statusUrl) { - quoteAvatar.setOnClickListener(view -> listener.onViewAccount(accountId)); - quoteDisplayName.setOnClickListener(view -> listener.onViewAccount(accountId)); - quoteUsername.setOnClickListener(view -> listener.onViewAccount(accountId)); - quoteContent.setOnClickListener(view -> listener.onViewUrl(statusUrl, statusUrl)); - quoteMedia.setOnClickListener(view -> listener.onViewUrl(statusUrl, statusUrl)); - quoteContainer.setOnClickListener(view -> listener.onViewUrl(statusUrl, statusUrl)); - } - - public void setupQuoteContainer() { - TimelineAccount account = quoteStatus.getAccount(); - setDisplayName(account.getName(), account.getEmojis()); - setUsername(account.getUsername()); - setContent( - quoteStatus.getContent(), - quoteStatus.getMentions(), - quoteStatus.getTags(), - quoteStatus.getEmojis(), - listener - ); - setAvatar(account.getAvatar(), avatarRadius24dp, statusDisplayOptions); - setOnClickListener(account.getId(), quoteStatus.getUrl()); - - if (quoteStatus.getSpoilerText().isEmpty()) { - hideSpoilerText(); - } else { - setSpoilerText(quoteStatus.getSpoilerText(), quoteStatus.getEmojis()); - } - - if (quoteStatus.getAttachments().size() == 0) { - quoteMedia.setVisibility(View.GONE); - } else { - quoteMedia.setVisibility(View.VISIBLE); - quoteMedia.setText(quoteContainer.getContext().getString(R.string.status_quote_media, - quoteStatus.getAttachments().size())); - } - } -} diff --git a/app/src/main/java/net/accelf/yuito/QuoteInlineHelper.kt b/app/src/main/java/net/accelf/yuito/QuoteInlineHelper.kt new file mode 100644 index 000000000..8ec23f28d --- /dev/null +++ b/app/src/main/java/net/accelf/yuito/QuoteInlineHelper.kt @@ -0,0 +1,120 @@ +package net.accelf.yuito + +import android.text.Spanned +import android.view.View +import androidx.annotation.Px +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ViewQuoteInlineBinding +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.HashTag +import com.keylesspalace.tusky.entity.Status.Mention +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.setClickableText +import com.keylesspalace.tusky.viewdata.StatusViewData + +class QuoteInlineHelper( + private val binding: ViewQuoteInlineBinding, + private val listener: LinkListener, + @Px private val avatarRadius24dp: Int, + private val statusDisplayOptions: StatusDisplayOptions, +) { + + private fun setDisplayName(name: String, customEmojis: List?) { + val viewDisplayName = binding.statusQuoteInlineDisplayName + val emojifiedName = name.emojify(customEmojis, viewDisplayName, statusDisplayOptions.animateEmojis) + viewDisplayName.text = emojifiedName + } + + private fun setUsername(name: String) { + val viewUserName = binding.statusQuoteInlineUsername + val context = viewUserName.context + val format = context.getString(R.string.post_username_format) + val usernameText = String.format(format, name) + viewUserName.text = usernameText + } + + private fun setContent( + content: Spanned, + mentions: List, + tags: List?, + emojis: List, + ) { + val viewContent = binding.statusQuoteInlineContent + val singleLineText = SpannedTextHelper.replaceSpanned(content) + val emojifiedText = singleLineText.emojify(emojis, viewContent, statusDisplayOptions.animateEmojis) + setClickableText(viewContent, emojifiedText, mentions, tags, listener) + } + + private fun setAvatar(url: String, @Px avatarRadius24dp: Int, statusDisplayOptions: StatusDisplayOptions) { + loadAvatar(url, binding.statusQuoteInlineAvatar, avatarRadius24dp, statusDisplayOptions.animateAvatars) + } + + private fun setSpoilerText(spoilerText: String, emojis: List) { + val viewDescription = binding.statusQuoteInlineContentWarningDescription + val viewButton = binding.statusQuoteInlineContentWarningButton + val emojiSpoiler = spoilerText.emojify(emojis, viewDescription, statusDisplayOptions.animateEmojis) + viewDescription.text = emojiSpoiler + viewDescription.visibility = View.VISIBLE + viewButton.visibility = View.VISIBLE + viewButton.setOnClickListener { + setContentVisibility(binding.statusQuoteInlineContent.visibility != View.VISIBLE) + } + setContentVisibility(false) + } + + private fun setContentVisibility(show: Boolean) { + binding.statusQuoteInlineContent.visibility = when (show) { + true -> View.VISIBLE + false -> View.GONE + } + binding.statusQuoteInlineContentWarningButton.setText(when (show) { + true -> R.string.post_content_warning_show_less + false -> R.string.post_content_warning_show_more + }) + } + + private fun hideSpoilerText() { + binding.statusQuoteInlineContentWarningDescription.visibility = View.GONE + binding.statusQuoteInlineContentWarningButton.visibility = View.GONE + binding.statusQuoteInlineContent.visibility = View.VISIBLE + } + + private fun setOnClickListener(accountId: String, statusUrl: String?) { + binding.statusQuoteInlineAvatar.setOnClickListener { listener.onViewAccount(accountId) } + binding.statusQuoteInlineDisplayName.setOnClickListener { listener.onViewAccount(accountId) } + binding.statusQuoteInlineUsername.setOnClickListener { listener.onViewAccount(accountId) } + binding.statusQuoteInlineContent.setOnClickListener { listener.onViewUrl(statusUrl!!, statusUrl) } + binding.statusQuoteInlineMedia.setOnClickListener { listener.onViewUrl(statusUrl!!, statusUrl) } + binding.root.setOnClickListener { listener.onViewUrl(statusUrl!!, statusUrl) } + } + + fun setupQuoteContainer(quote: StatusViewData.Concrete) { + val actionable = quote.actionable + val account = actionable.account + setDisplayName(account.name, account.emojis) + setUsername(account.username) + setContent( + quote.content, + actionable.mentions, + actionable.tags, + actionable.emojis, + ) + setAvatar(account.avatar, avatarRadius24dp, statusDisplayOptions) + setOnClickListener(account.id, actionable.url) + if (quote.spoilerText.isEmpty()) { + hideSpoilerText() + } else { + setSpoilerText(quote.spoilerText, actionable.emojis) + } + val viewMedia = binding.statusQuoteInlineMedia + if (actionable.attachments.size == 0) { + viewMedia.visibility = View.GONE + } else { + viewMedia.visibility = View.VISIBLE + viewMedia.text = viewMedia.context.getString(R.string.status_quote_media, actionable.attachments.size) + } + } +} diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index b55faa6c2..bf2a2d521 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -253,9 +253,6 @@ Mìnich e dhan fheadhainn air a bheil cion-lèirsinn \n(%d caractar(an) air a char as fhaide) - - - Cha deach leinn am fo-thiotal a shuidheachadh A’ postadh leis a’ chunntas %1$s diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index e99b718a8..bfabe4c8e 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -54,7 +54,7 @@ Seguir Tes a certeza de que queres desconectar a conta %1$s\? Desconectar - Conecta con Mastodon + Accede con Mastodon Redactar Máis Eliminar favorito @@ -116,7 +116,7 @@ Os ficheiros de vídeo teñen que ser menores de 40MB. O ficheiro debe ser menor de 8MB. A publicación é demasiado longa! - Fallou a obtención do token de conexión. + Fallou a obtención do token de acceso. A autorización foi rexeitada. Aconteceu un erro non identificado de autorización. Non se atopou un navegador para utilizar. @@ -411,8 +411,8 @@ Bloquear @%s\? Agochar todo o dominio Tes a certeza de querer bloquear a todo %s\? Non verás o contido dese dominio en ningunha cronoloxía pública ou nas notificacións. As túas seguidoras nese dominio serán eliminadas. - Eliminar e reescribir este toot\? - Eliminar este toot\? + Eliminar e reescribir esta publicación\? + Eliminar esta publicación\? Deixar de seguir esta conta\? Revogar a solicitude de seguimento\? Descargar diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index be5ee7524..0dc453c5c 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -21,23 +21,23 @@ Óskilgreind auðkenningarvilla kom upp. Heimild var hafnað. Mistókst að fá innskráningarteikn. - Stöðufærslan er of löng! + Færslan er of löng! Skráin verður að vera minni en 8MB. Myndskeiðaskrár verða að vera minni en 40MB. Þessa tegund skrár er ekki hægt að senda inn. Ekki var hægt að opna skrána. Krafist er heimilda til að lesa gögn. Krafist er heimilda til að geyma gögn. - Ekki er hægt að hengja bæði myndir og myndskeið við sömu stöðufærslu. + Ekki er hægt að hengja bæði myndir og myndskeið við sömu færslu. Sendingin mistókst. - Villa við að senda tíst. + Villa við að senda færslu. Heim Tilkynningar Staðvært Sameiginlegt Bein skilaboð Flipar - Tíst + Þráður Færslur Með svörum Fest @@ -62,8 +62,8 @@ Fella saman Ekkert hér. Ekkert hér. Togaðu niður til að endurhlaða! - %s endurbirti tístið þitt - %s setti tíst frá þér í eftirlæti + %s endurbirti færsluna þína + %s setti færslu frá þér í eftirlæti %s fylgist núna með þér Kæra @%s Aðrar athugasemdir\? @@ -117,7 +117,7 @@ Hafna Drög Áætluð tíst - Sýnileiki tísts + Sýnileiki færslu Aðvörun vegna efnis Lyklaborð með tjáningartáknum Tímasetja tíst @@ -234,9 +234,9 @@ Nýir fylgjendur Tilkynningar um nýja fylgjendur Endurbirtingar - Tilkynningar þegar tístin þín eru endurbirt + Tilkynningar þegar færslurnar þínar eru endurbirtar Eftirlæti - Tilkynningar þegar tístin þín eru sett í eftirlæti + Tilkynningar þegar færslurnar þínar eru settar í eftirlæti Kannanir Tilkynningar um kannanir sem er lokið %s minntist á þig @@ -273,7 +273,7 @@ %ds Fylgir þér Alltaf birta myndefni sem merkt er viðkvæmt - Alltaf fletta út tístum sem eru með aðvörun vegna efnis + Alltaf fletta út færslum sem eru með aðvörun vegna efnis Gagnaskrár Svar til @%s hlaða inn fleiru @@ -376,7 +376,7 @@ Hreinsa Sía Virkja - Semja tíst + Semja færslu Semja skilaboð Ertu viss um að þú viljir endanlega eyða öllum tilkynningunum þínum\? Aðgerðir fyrir mynd %s @@ -478,7 +478,7 @@ Sumar upplýsingar sem gætu haft áhrif á andlega vellíðan þína verða faldar. Þetta hefur áhrif á: \n \n - Eftirlæti/Endurbirtingar/Tilkynningar um fylgjendabeiðnir -\n - Eftirlæti/Talningu á endurbirtingum tísta +\n - Eftirlæti/Talningu á endurbirtingum færslna \n - Fylgjendur/Tölfræði færslna í notendasniðum \n \n Þetta hefur ekki áhrif á ýti-tilkynningar, en þú getur yfirfarið handvirkt kjörstillingar þínar varðandi tilkynningar. @@ -503,9 +503,9 @@ Takmarka tilkynningar á tímalínu Yfirfara tilkynningar Vellíðan - Tilkynningar þegar einhver sem þú ert áskrifandi að hefur birt nýtt tíst - Ný tíst - einhver sem ég er áskrifandi að birti nýtt tíst + Tilkynningar þegar einhver sem þú ert áskrifandi að hefur birt nýja færslu + Nýjar færslur + einhver sem ég er áskrifandi að birti nýja færslu %s sendi inn rétt í þessu Jafnvel þótt aðgangurinn þinn sé ekki læstur, fannst starfsfólki %1$s að þú gætir viljað yfirfara handvirkt fylgjendabeiðnir frá þessum aðgöngum. Fjarlægja bókamerki @@ -518,4 +518,5 @@ 180 dagar 365 dagar 14 dagar + Semja færslu \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 4c38795d6..04f1a3e8b 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -506,10 +506,10 @@ Ogranicz liczbę powiadomień o zmianach na osi czasu Czas trwania Nowe wpisy - Niektóre informacje, które mogą wpływać na Twoj dobrostan psychiczny zostaną ukryte. W ich skład wchodzą: + Niektóre informacje, które mogą wpływać na Twój dobrostan psychiczny zostaną ukryte. W ich skład wchodzą: \n \n - powiadomienia o ulubionych/podbiciach/obserwowaniu -\n - liczba polubień/podbić toota +\n - liczba polubień/podbić wpisu \n - statystyki obserwujących/postów na profilach \n \nNie będzie to miało wpływu na powiadomienia typu push, ale możesz zmienić ustawienia powiadomień ręcznie. @@ -542,4 +542,11 @@ %s poprosił(a) o możliwość śledzenia Cię Usuń z zakładek Pytaj o potwierdzenie przed dodaniem do ulubionych + 14 dni + 30 dni + 60 dni + 90 dni + 180 dni + 365 dni + Utwórz wpis \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 0c78cfa83..0031334b8 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -16,7 +16,7 @@ Сповіщення Головна Помилка надсилання допису. - Зображення та відео не можуть бути прикріплені до статусу одночасно. + Зображення та відео не можуть бути прикріплені до допису одночасно. Потрібен дозвіл на зберігання медіа. Потрібен дозвіл на читання медіа. Не вдається відкрити цей файл. @@ -24,7 +24,7 @@ Аудіофайли повинні бути менше 40 МБ. Відео повинне бути менше 40 МБ. Файл повинен бути менше 8 МБ. - Статус надто довгий! + Допис задовгий! Не вдалося знайти браузер, який можна використати. Не може бути порожнім. Сталася помилка мережі! Перевірте інтернет-з\'єднання та спробуйте знову! diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index cd3f07248..caf759fee 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -2,7 +2,7 @@ Bạn có muốn xóa toàn bộ thông báo\? Đã lưu tút vào nháp - Đã hủy đăng + Hủy đăng Đăng Tút Đang đăng… @@ -192,7 +192,7 @@ Ghim Trả lời Tút - Chuỗi tút + Nội dung tút Xếp tab Tin nhắn Thế giới @@ -394,7 +394,7 @@ Những người thích tút này Những người đăng lại tút này - %s lượt đăng lại + %s Đăng lại %1$s Thích diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9947c3805..a7d2e6e01 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -67,6 +67,7 @@ %s favorited your post %s followed you %s requested to follow you + %s signed up %s just posted Report @%s @@ -245,6 +246,7 @@ my posts are favorited polls have ended somebody I\'m subscribed to published a new post + somebody signed up Appearance App Theme Timelines @@ -321,6 +323,8 @@ Notifications about polls that have ended New posts Notifications when somebody you\'re subscribed to published a new post + Sign ups + Notifications about new users %s mentioned you %1$s, %2$s, %3$s and %4$d others diff --git a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt index 19066f42e..8f97f3c42 100644 --- a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt @@ -15,16 +15,11 @@ package com.keylesspalace.tusky -import android.text.SpannedString -import android.widget.LinearLayout import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.google.android.material.bottomsheet.BottomSheetBehavior import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.network.MastodonApi -import com.nhaarman.mockitokotlin2.doReturn -import com.nhaarman.mockitokotlin2.mock import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.plugins.RxJavaPlugins @@ -39,8 +34,8 @@ import org.junit.runner.RunWith import org.junit.runners.Parameterized import org.mockito.ArgumentMatchers.anyBoolean import org.mockito.Mockito.eq -import org.mockito.Mockito.mock -import java.util.ArrayList +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock import java.util.Date import java.util.concurrent.TimeUnit @@ -74,7 +69,7 @@ class BottomSheetActivityTest { inReplyToId = null, inReplyToAccountId = null, reblog = null, - content = SpannedString("omgwat"), + content = "omgwat", createdAt = Date(), emojis = emptyList(), reblogsCount = 0, @@ -307,7 +302,7 @@ class BottomSheetActivityTest { init { mastodonApi = api @Suppress("UNCHECKED_CAST") - bottomSheet = mock(BottomSheetBehavior::class.java) as BottomSheetBehavior + bottomSheet = mock() } override fun openLink(url: String) { diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt index e7b3a1a91..5396a21ec 100644 --- a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt @@ -17,15 +17,12 @@ package com.keylesspalace.tusky import android.content.Intent import android.os.Looper.getMainLooper -import android.text.SpannedString import android.widget.EditText import androidx.test.ext.junit.runners.AndroidJUnit4 import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.compose.DEFAULT_CHARACTER_LIMIT import com.keylesspalace.tusky.components.compose.DEFAULT_MAXIMUM_URL_LENGTH -import com.keylesspalace.tusky.components.compose.MediaUploader -import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase @@ -37,18 +34,16 @@ import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.InstanceConfiguration import com.keylesspalace.tusky.entity.StatusConfiguration import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.service.ServiceClient -import com.nhaarman.mockitokotlin2.any import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.core.SingleObserver import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mockito.`when` -import org.mockito.Mockito.mock +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock import org.robolectric.Robolectric import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config @@ -94,44 +89,47 @@ class ComposeActivityTest { val controller = Robolectric.buildActivity(ComposeActivity::class.java) activity = controller.get() - accountManagerMock = mock(AccountManager::class.java) - `when`(accountManagerMock.activeAccount).thenReturn(account) + accountManagerMock = mock { + on { activeAccount } doReturn account + } - apiMock = mock(MastodonApi::class.java) - `when`(apiMock.getCustomEmojis()).thenReturn(Single.just(emptyList())) - `when`(apiMock.getInstance()).thenReturn(object : Single() { - override fun subscribeActual(observer: SingleObserver) { - val instance = instanceResponseCallback?.invoke() + apiMock = mock { + on { getCustomEmojis() } doReturn Single.just(emptyList()) + onBlocking { getInstance() } doReturn instanceResponseCallback?.invoke().let { instance -> if (instance == null) { - observer.onError(Throwable()) + Result.failure(Throwable()) } else { - observer.onSuccess(instance) + Result.success(instance) } } - }) + } - val instanceDaoMock = mock(InstanceDao::class.java) - `when`(instanceDaoMock.loadMetadataForInstance(any())).thenReturn( - Single.just(InstanceEntity(instanceDomain, emptyList(), null, null, null, null, null, null, null)) - ) + val instanceDaoMock: InstanceDao = mock { + on { loadMetadataForInstance(any()) } doReturn + Single.just(InstanceEntity(instanceDomain, emptyList(), null, null, null, null, null, null, null)) + on { loadMetadataForInstance(any()) } doReturn + Single.just(InstanceEntity(instanceDomain, emptyList(), null, null, null, null, null, null, null)) + } - val dbMock = mock(AppDatabase::class.java) - `when`(dbMock.instanceDao()).thenReturn(instanceDaoMock) + val dbMock: AppDatabase = mock { + on { instanceDao() } doReturn instanceDaoMock + } val viewModel = ComposeViewModel( apiMock, accountManagerMock, - mock(MediaUploader::class.java), - mock(ServiceClient::class.java), - mock(DraftHelper::class.java), + mock(), + mock(), + mock(), dbMock ) activity.intent = Intent(activity, ComposeActivity::class.java).apply { putExtra(ComposeActivity.COMPOSE_OPTIONS_EXTRA, composeOptions) } - val viewModelFactoryMock = mock(ViewModelFactory::class.java) - `when`(viewModelFactoryMock.create(ComposeViewModel::class.java)).thenReturn(viewModel) + val viewModelFactoryMock: ViewModelFactory = mock { + on { create(ComposeViewModel::class.java) } doReturn viewModel + } activity.accountManager = accountManagerMock activity.viewModelFactory = viewModelFactoryMock @@ -470,7 +468,7 @@ class ComposeActivityTest { "admin", "admin", "admin", - SpannedString(""), + "", "https://example.token", "", "", @@ -490,7 +488,7 @@ class ComposeActivityTest { ) } - fun getCustomInstanceConfiguration(maximumStatusCharacters: Int? = null, charactersReservedPerUrl: Int? = null): InstanceConfiguration { + private fun getCustomInstanceConfiguration(maximumStatusCharacters: Int? = null, charactersReservedPerUrl: Int? = null): InstanceConfiguration { return InstanceConfiguration( statuses = StatusConfiguration( maxCharacters = maximumStatusCharacters, diff --git a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt index fd8994e09..d3455ec27 100644 --- a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt @@ -1,6 +1,5 @@ package com.keylesspalace.tusky -import android.text.SpannedString import androidx.test.ext.junit.runners.AndroidJUnit4 import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Filter @@ -8,12 +7,12 @@ import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.PollOption import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel -import com.nhaarman.mockitokotlin2.mock import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.mock import org.robolectric.annotation.Config import java.util.ArrayList import java.util.Date @@ -22,7 +21,7 @@ import java.util.Date @RunWith(AndroidJUnit4::class) class FilterTest { - lateinit var filterModel: FilterModel + private lateinit var filterModel: FilterModel @Before fun setup() { @@ -162,7 +161,7 @@ class FilterTest { inReplyToId = null, inReplyToAccountId = null, reblog = null, - content = SpannedString(content), + content = content, createdAt = Date(), emojis = emptyList(), reblogsCount = 0, diff --git a/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt b/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt index ed06e27c6..3086036a0 100644 --- a/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt @@ -1,10 +1,8 @@ package com.keylesspalace.tusky -import android.text.Spanned import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.gson.GsonBuilder +import com.google.gson.Gson import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.json.SpannedTypeAdapter import com.keylesspalace.tusky.viewdata.StatusViewData import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals @@ -39,9 +37,7 @@ class StatusComparisonTest { assertEquals(createStatus(note = "Test"), createStatus(note = "Test 123456")) } - private val gson = GsonBuilder().registerTypeAdapter( - Spanned::class.java, SpannedTypeAdapter() - ).create() + private val gson = Gson() @Test fun `two equal status view data - should be equal`() { @@ -49,14 +45,12 @@ class StatusComparisonTest { status = createStatus(), isExpanded = false, isShowingContent = false, - isCollapsible = false, isCollapsed = false ) val viewdata2 = StatusViewData.Concrete( status = createStatus(), isExpanded = false, isShowingContent = false, - isCollapsible = false, isCollapsed = false ) assertEquals(viewdata1, viewdata2) @@ -68,14 +62,12 @@ class StatusComparisonTest { status = createStatus(), isExpanded = true, isShowingContent = false, - isCollapsible = false, isCollapsed = false ) val viewdata2 = StatusViewData.Concrete( status = createStatus(), isExpanded = false, isShowingContent = false, - isCollapsible = false, isCollapsed = false ) assertNotEquals(viewdata1, viewdata2) @@ -87,14 +79,12 @@ class StatusComparisonTest { status = createStatus(content = "whatever"), isExpanded = true, isShowingContent = false, - isCollapsible = false, isCollapsed = false ) val viewdata2 = StatusViewData.Concrete( status = createStatus(), isExpanded = false, isShowingContent = false, - isCollapsible = false, isCollapsed = false ) assertNotEquals(viewdata1, viewdata2) diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt index 462b0a4a0..2778f8c26 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -17,9 +17,6 @@ import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.db.TimelineStatusWithAccount -import com.nhaarman.mockitokotlin2.anyOrNull -import com.nhaarman.mockitokotlin2.doReturn -import com.nhaarman.mockitokotlin2.mock import io.reactivex.rxjava3.core.Single import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking @@ -31,6 +28,9 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import retrofit2.HttpException diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt index 2e67c6fe3..33215e675 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt @@ -1,14 +1,19 @@ package com.keylesspalace.tusky.components.timeline import androidx.paging.PagingSource +import androidx.test.ext.junit.runners.AndroidJUnit4 import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelinePagingSource import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel -import com.nhaarman.mockitokotlin2.doReturn -import com.nhaarman.mockitokotlin2.mock import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.robolectric.annotation.Config +@Config(sdk = [28]) +@RunWith(AndroidJUnit4::class) class NetworkTimelinePagingSourceTest { private val status = mockStatusViewData() diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt index 74d0fe257..eabf744c2 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt @@ -12,11 +12,6 @@ import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineView import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.viewdata.StatusViewData -import com.nhaarman.mockitokotlin2.anyOrNull -import com.nhaarman.mockitokotlin2.doReturn -import com.nhaarman.mockitokotlin2.doThrow -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.verify import kotlinx.coroutines.runBlocking import okhttp3.Headers import okhttp3.ResponseBody.Companion.toResponseBody @@ -24,6 +19,11 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.robolectric.annotation.Config import retrofit2.HttpException import retrofit2.Response @@ -331,7 +331,6 @@ class NetworkTimelineRemoteMediatorTest { mockStatusViewData("2"), mockStatusViewData("1"), ) - verify(timelineViewModel).nextKey = "0" assertTrue(result is RemoteMediator.MediatorResult.Success) assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt index 13a7b338a..d4ca1f4de 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt @@ -1,6 +1,5 @@ package com.keylesspalace.tusky.components.timeline -import android.text.SpannedString import com.google.gson.Gson import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.entity.Status @@ -25,7 +24,7 @@ fun mockStatus(id: String = "100") = Status( inReplyToId = null, inReplyToAccountId = null, reblog = null, - content = SpannedString("Test"), + content = "Test", createdAt = fixedDate, emojis = emptyList(), reblogsCount = 1, @@ -51,7 +50,6 @@ fun mockStatusViewData(id: String = "100") = StatusViewData.Concrete( status = mockStatus(id), isExpanded = false, isShowingContent = false, - isCollapsible = false, isCollapsed = true, ) diff --git a/build.gradle b/build.gradle index c93117011..3a5251fa0 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,4 @@ buildscript { - ext.kotlin_version = '1.6.10' repositories { google() mavenCentral() @@ -7,12 +6,12 @@ buildscript { } dependencies { classpath "com.android.tools.build:gradle:7.1.2" - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath "org.jlleitschuh.gradle:ktlint-gradle:10.1.0" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.20" + classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.1" } } plugins { - id "org.jlleitschuh.gradle.ktlint" version "10.1.0" + id "org.jlleitschuh.gradle.ktlint" version "10.2.1" } allprojects { diff --git a/fastlane/metadata/android/pl/changelogs/87.txt b/fastlane/metadata/android/pl/changelogs/87.txt new file mode 100644 index 000000000..2b8d41f69 --- /dev/null +++ b/fastlane/metadata/android/pl/changelogs/87.txt @@ -0,0 +1,8 @@ +Tusky v16.0 + +- Logika ładowania osi czasu została przepisana w celu przyspieszenia jej i naprawienia błędów. +- Tusky wspiera teraz animowane emotikony w formatach APNG i Animated WebP. +- Mnóstwo poprawek +- Wsparcie dla Androida 11 +- Nowe tłumaczenia: Gaelicki szkocki, galicyjski, ukraiński +- Ulepszone tłumaczenia diff --git a/fastlane/metadata/android/uk/changelogs/89.txt b/fastlane/metadata/android/uk/changelogs/89.txt new file mode 100644 index 000000000..fbed5e83d --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/89.txt @@ -0,0 +1,7 @@ +Tusky v17.0 + +- «Відкрити як...» тепер також доступно в меню профілів облікових записів за користування кількома обліковими записами +- Тепер вхід обробляється у WebView у застосунку +- Підтримка Android 12 +- підтримка нового API конфігурації сервера Mastodon +- і багато інших дрібних виправлень і вдосконалень