From 940d6d395a98518467662f5f7275851d59e0aa9c Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Thu, 21 Jan 2021 18:57:09 +0100 Subject: [PATCH] Drafts v2 (#2032) * cleanup warnings, reorganize some code * move ComposeAutoCompleteAdapter to compose package * composeOptions doesn't need to be a class member * add DraftsActivity and DraftsViewModel * drafts * remove unnecessary Unit in ComposeViewModel * add schema/25.json * fix db migration * drafts * cleanup code * fix compose activity rotation bug * fix media descriptions getting lost when restoring a draft * improve deleting drafts * fix ComposeActivityTest * improve draft layout for almost empty drafts * reformat code * show toast when opening reply to deleted toot * improve item_draft layout --- app/build.gradle | 3 + .../25.json | 821 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 1 + .../com/keylesspalace/tusky/MainActivity.kt | 32 +- .../tusky/SavedTootActivity.java | 4 +- .../components/compose/ComposeActivity.kt | 86 +- .../compose}/ComposeAutoCompleteAdapter.java | 2 +- .../components/compose/ComposeViewModel.kt | 159 ++-- .../tusky/components/compose/MediaUploader.kt | 8 +- .../tusky/components/drafts/DraftHelper.kt | 159 ++++ .../components/drafts/DraftMediaAdapter.kt | 81 ++ .../tusky/components/drafts/DraftsActivity.kt | 197 +++++ .../tusky/components/drafts/DraftsAdapter.kt | 92 ++ .../components/drafts/DraftsViewModel.kt | 69 ++ .../scheduled/ScheduledTootActivity.kt | 2 +- .../keylesspalace/tusky/db/AppDatabase.java | 24 +- .../com/keylesspalace/tusky/db/Converters.kt | 26 +- .../com/keylesspalace/tusky/db/DraftDao.kt | 40 + .../com/keylesspalace/tusky/db/DraftEntity.kt | 55 ++ .../tusky/db/TimelineStatusEntity.kt | 2 +- .../com/keylesspalace/tusky/db/TootDao.java | 11 +- .../tusky/di/ActivitiesModule.kt | 4 + .../com/keylesspalace/tusky/di/AppModule.kt | 2 +- .../tusky/di/ViewModelFactory.kt | 6 + .../tusky/network/MastodonApi.kt | 8 +- .../receiver/SendStatusBroadcastReceiver.kt | 34 +- .../tusky/service/SendTootService.kt | 34 +- .../tusky/util/BindingViewHolder.kt | 8 + .../tusky/util/RxAwareViewModel.kt | 2 + .../tusky/util/SaveTootHelper.java | 150 +--- app/src/main/res/drawable/ic_alert_circle.xml | 8 + app/src/main/res/drawable/ic_notebook.xml | 2 +- .../layout-sw640dp/fragment_view_thread.xml | 4 +- app/src/main/res/layout/activity_compose.xml | 3 + app/src/main/res/layout/activity_drafts.xml | 34 + .../main/res/layout/fragment_view_thread.xml | 4 +- app/src/main/res/layout/item_draft.xml | 95 ++ app/src/main/res/layout/toolbar_basic.xml | 27 +- app/src/main/res/menu/drafts.xml | 10 + app/src/main/res/values-ar/strings.xml | 2 +- app/src/main/res/values-ber/strings.xml | 2 +- app/src/main/res/values-bn-rBD/strings.xml | 2 +- app/src/main/res/values-bn-rIN/strings.xml | 2 +- app/src/main/res/values-ca/strings.xml | 2 +- app/src/main/res/values-cs/strings.xml | 2 +- app/src/main/res/values-cy/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-eo/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-eu/strings.xml | 2 +- app/src/main/res/values-fa/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-ga/strings.xml | 2 +- app/src/main/res/values-gd/strings.xml | 2 +- app/src/main/res/values-hi/strings.xml | 2 +- app/src/main/res/values-hu/strings.xml | 2 +- app/src/main/res/values-is/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-ja/strings.xml | 2 +- app/src/main/res/values-kab/strings.xml | 2 +- app/src/main/res/values-ko/strings.xml | 2 +- app/src/main/res/values-ml/strings.xml | 2 +- app/src/main/res/values-nl/strings.xml | 2 +- app/src/main/res/values-no-rNB/strings.xml | 2 +- app/src/main/res/values-oc/strings.xml | 2 +- app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values-pt-rBR/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-sa/strings.xml | 2 +- app/src/main/res/values-sk/strings.xml | 2 +- app/src/main/res/values-sl/strings.xml | 2 +- app/src/main/res/values-sv/strings.xml | 2 +- app/src/main/res/values-ta/strings.xml | 2 +- app/src/main/res/values-th/strings.xml | 2 +- app/src/main/res/values-tr/strings.xml | 2 +- app/src/main/res/values-uk/strings.xml | 2 +- app/src/main/res/values-vi/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values-zh-rHK/strings.xml | 2 +- app/src/main/res/values-zh-rMO/strings.xml | 2 +- app/src/main/res/values-zh-rSG/strings.xml | 2 +- app/src/main/res/values-zh-rTW/strings.xml | 2 +- app/src/main/res/values/dimens.xml | 1 + app/src/main/res/values/strings.xml | 14 +- .../tusky/ComposeActivityTest.kt | 3 +- 85 files changed, 2032 insertions(+), 381 deletions(-) create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json rename app/src/main/java/com/keylesspalace/tusky/{adapter => components/compose}/ComposeAutoCompleteAdapter.java (99%) create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/BindingViewHolder.kt create mode 100644 app/src/main/res/drawable/ic_alert_circle.xml create mode 100644 app/src/main/res/layout/activity_drafts.xml create mode 100644 app/src/main/res/layout/item_draft.xml create mode 100644 app/src/main/res/menu/drafts.xml diff --git a/app/build.gradle b/app/build.gradle index a7f566ac6..fa6fc401d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -67,6 +67,9 @@ android { androidExtensions { experimental = true } + buildFeatures { + viewBinding true + } testOptions { unitTests { returnDefaultValues = true diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json new file mode 100644 index 000000000..01a491b4a --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json @@ -0,0 +1,821 @@ +{ + "formatVersion": 1, + "database": { + "version": 25, + "identityHash": "e2cb844862443c2c5cc884c11f120d43", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "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, `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": "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" + ], + "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, `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": "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, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, 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": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "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 + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "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_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` 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.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.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, 'e2cb844862443c2c5cc884c11f120d43')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 585ff833c..770d45af6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -146,6 +146,7 @@ + + val showDraftWarning = preferences.getBoolean(sharedPrefsKey, true) + if (draftCount > 0 && showDraftWarning) { + AlertDialog.Builder(this) + .setMessage(R.string.new_drafts_warning) + .setNegativeButton("Don't show again") { _, _ -> + preferences.edit(commit = true) { + putBoolean(sharedPrefsKey, false) + } + } + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + + } + override fun getActionButton(): FloatingActionButton? = composeButton override fun androidInjector() = androidInjector diff --git a/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java b/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java index 9a1639898..8e2b5acb2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java @@ -89,7 +89,7 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd setSupportActionBar(toolbar); ActionBar bar = getSupportActionBar(); if (bar != null) { - bar.setTitle(getString(R.string.title_saved_toot)); + bar.setTitle(getString(R.string.title_drafts)); bar.setDisplayHomeAsUpEnabled(true); bar.setDisplayShowHomeEnabled(true); } @@ -166,6 +166,7 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd ComposeOptions composeOptions = new ComposeOptions( /*scheduledTootUid*/null, item.getUid(), + /*drafId*/null, item.getText(), jsonUrls, descriptions, @@ -177,6 +178,7 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd item.getInReplyToUsername(), item.getInReplyToText(), /*mediaAttachments*/null, + /*draftAttachments*/null, /*scheduledAt*/null, /*sensitive*/null, /*poll*/null, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt index 5306e57ae..69a090880 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -30,7 +30,6 @@ import android.os.Build import android.os.Bundle import android.os.Parcelable import android.provider.MediaStore -import android.text.TextUtils import android.util.Log import android.view.KeyEvent import android.view.MenuItem @@ -57,13 +56,13 @@ import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter import com.keylesspalace.tusky.adapter.EmojiAdapter import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.DraftAttachment import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Attachment @@ -81,7 +80,6 @@ import java.io.File import java.io.IOException import java.util.* import javax.inject.Inject -import kotlin.collections.ArrayList import kotlin.math.max import kotlin.math.min @@ -104,10 +102,10 @@ class ComposeActivity : BaseActivity(), // this only exists when a status is trying to be sent, but uploads are still occurring private var finishingUploadDialog: ProgressDialog? = null private var photoUploadUri: Uri? = null + @VisibleForTesting var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT - private var composeOptions: ComposeOptions? = null private val viewModel: ComposeViewModel by viewModels { viewModelFactory } private val maxUploadMediaNumber = 4 @@ -148,17 +146,17 @@ class ComposeActivity : BaseActivity(), /* If the composer is started up as a reply to another post, override the "starting" state * based on what the intent from the reply request passes. */ - if (intent != null) { - this.composeOptions = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA) - viewModel.setup(composeOptions) - setupReplyViews(composeOptions?.replyingStatusAuthor) - val tootText = composeOptions?.tootText - if (!tootText.isNullOrEmpty()) { - composeEditField.setText(tootText) - } + + val composeOptions: ComposeOptions? = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA) + + viewModel.setup(composeOptions) + setupReplyViews(composeOptions?.replyingStatusAuthor, composeOptions?.replyingStatusContent) + val tootText = composeOptions?.tootText + if (!tootText.isNullOrEmpty()) { + composeEditField.setText(tootText) } - if (!TextUtils.isEmpty(composeOptions?.scheduledAt)) { + if (!composeOptions?.scheduledAt.isNullOrEmpty()) { composeScheduleView.setDateTime(composeOptions?.scheduledAt) } @@ -169,38 +167,24 @@ class ComposeActivity : BaseActivity(), viewModel.setupComplete.value = true } - private fun applyShareIntent(intent: Intent?, savedInstanceState: Bundle?) { - if (intent != null && savedInstanceState == null) { + private fun applyShareIntent(intent: Intent, savedInstanceState: Bundle?) { + if (savedInstanceState == null) { /* Get incoming images being sent through a share action from another app. Only do this * when savedInstanceState is null, otherwise both the images from the intent and the * instance state will be re-queued. */ - val type = intent.type - if (type != null) { + intent.type?.also { type -> if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) { - val uriList = ArrayList() - if (intent.action != null) { - when (intent.action) { - Intent.ACTION_SEND -> { - val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) - if (uri != null) { - uriList.add(uri) - } - } - Intent.ACTION_SEND_MULTIPLE -> { - val list = intent.getParcelableArrayListExtra( - Intent.EXTRA_STREAM) - if (list != null) { - for (uri in list) { - if (uri != null) { - uriList.add(uri) - } - } - } + when (intent.action) { + Intent.ACTION_SEND -> { + intent.getParcelableExtra(Intent.EXTRA_STREAM)?.let { uri -> + pickMedia(uri) + } + } + Intent.ACTION_SEND_MULTIPLE -> { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.forEach { uri -> + pickMedia(uri) } } - } - for (uri in uriList) { - pickMedia(uri) } } else if (type == "text/plain" && intent.action == Intent.ACTION_SEND) { @@ -224,7 +208,7 @@ class ComposeActivity : BaseActivity(), } } - private fun setupReplyViews(replyingStatusAuthor: String?) { + private fun setupReplyViews(replyingStatusAuthor: String?, replyingStatusContent: String?) { if (replyingStatusAuthor != null) { composeReplyView.show() composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor) @@ -248,7 +232,7 @@ class ComposeActivity : BaseActivity(), } } } - composeOptions?.replyingStatusContent?.let { composeReplyContentView.text = it } + replyingStatusContent?.let { composeReplyContentView.text = it } } private fun setupContentWarningField(startingContentWarning: String?) { @@ -651,7 +635,6 @@ class ComposeActivity : BaseActivity(), } } - private fun removePoll() { viewModel.poll.value = null pollPreview.hide() @@ -835,22 +818,22 @@ class ComposeActivity : BaseActivity(), override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { super.onActivityResult(requestCode, resultCode, intent) if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_PICK_RESULT && intent != null) { - if(intent.data != null){ + if (intent.data != null) { // Single media, upload it and done. pickMedia(intent.data!!) - }else if(intent.clipData != null){ + } else if (intent.clipData != null) { val clipData = intent.clipData!! val count = clipData.itemCount - if(mediaCount + count > maxUploadMediaNumber){ + if (mediaCount + count > maxUploadMediaNumber) { // check if exist media + upcoming media > 4, then prob error message. Toast.makeText(this, getString(R.string.error_upload_max_media_reached, maxUploadMediaNumber), Toast.LENGTH_SHORT).show() - }else{ + } else { // if not grater then 4, upload all multiple media. for (i in 0 until count) { - val imageUri = clipData.getItemAt(i).getUri() - pickMedia(imageUri) - } + val imageUri = clipData.getItemAt(i).getUri() + pickMedia(imageUri) } + } } } else if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) { pickMedia(photoUploadUri!!) @@ -1018,8 +1001,9 @@ class ComposeActivity : BaseActivity(), @Parcelize data class ComposeOptions( // Let's keep fields var until all consumers are Kotlin - var scheduledTootUid: String? = null, + var scheduledTootId: String? = null, var savedTootUid: Int? = null, + var draftId: Int? = null, var tootText: String? = null, var mediaUrls: List? = null, var mediaDescriptions: List? = null, @@ -1031,6 +1015,7 @@ class ComposeActivity : BaseActivity(), var replyingStatusAuthor: String? = null, var replyingStatusContent: String? = null, var mediaAttachments: List? = null, + var draftAttachments: List? = null, var scheduledAt: String? = null, var sensitive: Boolean? = null, var poll: NewPoll? = null, @@ -1057,7 +1042,6 @@ class ComposeActivity : BaseActivity(), } } - @JvmStatic fun canHandleMimeType(mimeType: String?): Boolean { return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.startsWith("audio/") || mimeType == "text/plain") } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ComposeAutoCompleteAdapter.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java similarity index 99% rename from app/src/main/java/com/keylesspalace/tusky/adapter/ComposeAutoCompleteAdapter.java rename to app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java index ebc292ab4..df9ae8a81 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ComposeAutoCompleteAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.adapter; +package com.keylesspalace.tusky.components.compose; import android.content.Context; import android.preference.PreferenceManager; 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 2c015899b..a1a5e7e63 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 @@ -21,8 +21,8 @@ import androidx.core.net.toUri import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer -import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia +import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.search.SearchType import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase @@ -39,18 +39,12 @@ import io.reactivex.rxkotlin.Singles import java.util.* import javax.inject.Inject -/** - * Throw when trying to add an image when video is already present or the other way around - */ -class VideoOrImageException : Exception() - - -class ComposeViewModel -@Inject constructor( +class ComposeViewModel @Inject constructor( private val api: MastodonApi, private val accountManager: AccountManager, private val mediaUploader: MediaUploader, private val serviceClient: ServiceClient, + private val draftHelper: DraftHelper, private val saveTootHelper: SaveTootHelper, private val db: AppDatabase ) : RxAwareViewModel() { @@ -59,7 +53,8 @@ class ComposeViewModel private var replyingStatusContent: String? = null internal var startingText: String? = null private var savedTootUid: Int = 0 - private var scheduledTootUid: String? = null + private var draftId: Int = 0 + private var scheduledTootId: String? = null private var startingContentWarning: String = "" private var inReplyToId: String? = null private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN @@ -81,10 +76,6 @@ class ComposeViewModel val markMediaAsSensitive = mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false) - fun toggleMarkSensitive() { - this.markMediaAsSensitive.value = !this.markMediaAsSensitive.value!! - } - val statusVisibility = mutableLiveData(Status.Visibility.UNKNOWN) val showContentWarning = mutableLiveData(false) val setupComplete = mutableLiveData(false) @@ -96,7 +87,7 @@ class ComposeViewModel private val mediaToDisposable = mutableMapOf() - private val isEditingScheduledToot get() = !scheduledTootUid.isNullOrEmpty() + private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty() init { @@ -116,7 +107,7 @@ class ComposeViewModel .onErrorResumeNext( db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) ) - .subscribe ({ instanceEntity -> + .subscribe({ instanceEntity -> emoji.postValue(instanceEntity.emojiList) instance.postValue(instanceEntity) }, { throwable -> @@ -126,7 +117,7 @@ class ComposeViewModel .autoDispose() } - fun pickMedia(uri: Uri): LiveData> { + fun pickMedia(uri: Uri, description: String? = null): LiveData> { // We are not calling .toLiveData() here because we don't want to stop the process when // the Activity goes away temporarily (like on screen rotation). val liveData = MutableLiveData>() @@ -138,7 +129,7 @@ class ComposeViewModel && mediaItems[0].type == QueuedMedia.Type.IMAGE) { throw VideoOrImageException() } else { - addMediaToQueue(type, uri, size) + addMediaToQueue(type, uri, size, description) } } .subscribe({ queuedMedia -> @@ -150,12 +141,23 @@ class ComposeViewModel return liveData } - private fun addMediaToQueue(type: QueuedMedia.Type, uri: Uri, mediaSize: Long): QueuedMedia { - val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, mediaSize) + private fun addMediaToQueue( + type: QueuedMedia.Type, + uri: Uri, + mediaSize: Long, + description: String? = null + ): QueuedMedia { + val mediaItem = QueuedMedia( + localId = System.currentTimeMillis(), + uri = uri, + type = type, + mediaSize = mediaSize, + description = description + ) media.value = media.value!! + mediaItem mediaToDisposable[mediaItem.localId] = mediaUploader .uploadMedia(mediaItem) - .subscribe ({ event -> + .subscribe({ event -> val item = media.value?.find { it.localId == mediaItem.localId } ?: return@subscribe val newMediaItem = when (event) { @@ -190,6 +192,10 @@ class ComposeViewModel media.value = media.value!!.withoutFirstWhich { it.localId == item.localId } } + fun toggleMarkSensitive() { + this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true + } + fun didChange(content: String?, contentWarning: String?): Boolean { val textChanged = !(content.isNullOrEmpty() @@ -210,29 +216,37 @@ class ComposeViewModel } fun deleteDraft() { - saveTootHelper.deleteDraft(this.savedTootUid) + if (savedTootUid != 0) { + saveTootHelper.deleteDraft(savedTootUid) + } + if (draftId != 0) { + draftHelper.deleteDraftAndAttachments(draftId) + .subscribe() + } } fun saveDraft(content: String, contentWarning: String) { - val mediaUris = mutableListOf() - val mediaDescriptions = mutableListOf() - for (item in media.value!!) { + + val mediaUris: MutableList = mutableListOf() + val mediaDescriptions: MutableList = mutableListOf() + media.value?.forEach { item -> mediaUris.add(item.uri.toString()) mediaDescriptions.add(item.description) } - saveTootHelper.saveToot( - content, - contentWarning, - null, - mediaUris, - mediaDescriptions, - savedTootUid, - inReplyToId, - replyingStatusContent, - replyingStatusAuthor, - statusVisibility.value!!, - poll.value - ) + + draftHelper.saveDraft( + draftId = draftId, + accountId = accountManager.activeAccount?.id!!, + inReplyToId = inReplyToId, + content = content, + contentWarning = contentWarning, + sensitive = markMediaAsSensitive.value!!, + visibility = statusVisibility.value!!, + mediaUris = mediaUris, + mediaDescriptions = mediaDescriptions, + poll = poll.value, + failedToSend = false + ).subscribe() } /** @@ -246,7 +260,7 @@ class ComposeViewModel ): LiveData { val deletionObservable = if (isEditingScheduledToot) { - api.deleteScheduledStatus(scheduledTootUid.toString()).toObservable().map { Unit } + api.deleteScheduledStatus(scheduledTootId.toString()).toObservable().map { } } else { just(Unit) }.toLiveData() @@ -257,28 +271,30 @@ class ComposeViewModel val mediaIds = ArrayList() val mediaUris = ArrayList() val mediaDescriptions = ArrayList() + val mediaTypes = ArrayList() for (item in media.value!!) { mediaIds.add(item.id!!) mediaUris.add(item.uri) mediaDescriptions.add(item.description ?: "") + mediaTypes.add(item.type) } val tootToSend = TootToSend( - content, - spoilerText, - statusVisibility.value!!.serverString(), - mediaUris.isNotEmpty() && (markMediaAsSensitive.value!! || showContentWarning.value!!), - mediaIds, - mediaUris.map { it.toString() }, - mediaDescriptions, + text = content, + warningText = spoilerText, + visibility = statusVisibility.value!!.serverString(), + sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value!! || showContentWarning.value!!), + mediaIds = mediaIds, + mediaUris = mediaUris.map { it.toString() }, + mediaDescriptions = mediaDescriptions, scheduledAt = scheduledAt.value, inReplyToId = inReplyToId, poll = poll.value, replyingStatusContent = null, replyingStatusAuthorUsername = null, - savedJsonUrls = null, accountId = accountManager.activeAccount!!.id, - savedTootUid = 0, + savedTootUid = savedTootUid, + draftId = draftId, idempotencyKey = randomAlphanumericString(16), retries = 0 ) @@ -286,9 +302,7 @@ class ComposeViewModel serviceClient.sendToot(tootToSend) } - return combineLiveData(deletionObservable, sendObservable) { _, _ -> Unit } - - + return combineLiveData(deletionObservable, sendObservable) { _, _ -> } } fun updateDescription(localId: Long, description: String): LiveData { @@ -319,7 +333,6 @@ class ComposeViewModel return completedCaptioningLiveData } - fun searchAutocompleteSuggestions(token: String): List { when (token[0]) { '@' -> { @@ -370,14 +383,12 @@ class ComposeViewModel } } - override fun onCleared() { - for (uploadDisposable in mediaToDisposable.values) { - uploadDisposable.dispose() - } - super.onCleared() - } - fun setup(composeOptions: ComposeActivity.ComposeOptions?) { + + if (setupComplete.value == true) { + return + } + val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN @@ -385,6 +396,7 @@ class ComposeViewModel preferredVisibility.num.coerceAtLeast(replyVisibility.num)) inReplyToId = composeOptions?.inReplyToId + modifiedInitialState = composeOptions?.modifiedInitialState == true val contentWarning = composeOptions?.contentWarning @@ -396,10 +408,11 @@ class ComposeViewModel } // recreate media list - // when coming from SavedTootActivity val loadedDraftMediaUris = composeOptions?.mediaUrls val loadedDraftMediaDescriptions: List? = composeOptions?.mediaDescriptions + val draftAttachments = composeOptions?.draftAttachments if (loadedDraftMediaUris != null && loadedDraftMediaDescriptions != null) { + // when coming from SavedTootActivity loadedDraftMediaUris.zip(loadedDraftMediaDescriptions) .forEach { (uri, description) -> pickMedia(uri.toUri()).observeForever { errorOrItem -> @@ -408,23 +421,24 @@ class ComposeViewModel } } } + } else if (draftAttachments != null) { + // when coming from DraftActivity + draftAttachments.forEach { attachment -> pickMedia(attachment.uri, attachment.description) } } else composeOptions?.mediaAttachments?.forEach { a -> - // when coming from redraft + // when coming from redraft or ScheduledTootActivity val mediaType = when (a.type) { Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO - else -> QueuedMedia.Type.IMAGE } addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description) } - savedTootUid = composeOptions?.savedTootUid ?: 0 - scheduledTootUid = composeOptions?.scheduledTootUid + draftId = composeOptions?.draftId ?: 0 + scheduledTootId = composeOptions?.scheduledTootId startingText = composeOptions?.tootText - val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN if (tootVisibility.num != Status.Visibility.UNKNOWN.num) { startingVisibility = tootVisibility @@ -441,7 +455,6 @@ class ComposeViewModel startingText = builder.toString() } - scheduledAt.value = composeOptions?.scheduledAt composeOptions?.sensitive?.let { markMediaAsSensitive.value = it } @@ -462,6 +475,13 @@ class ComposeViewModel scheduledAt.value = newScheduledAt } + override fun onCleared() { + for (uploadDisposable in mediaToDisposable.values) { + uploadDisposable.dispose() + } + super.onCleared() + } + private companion object { const val TAG = "ComposeViewModel" } @@ -479,4 +499,9 @@ data class ComposeInstanceParams( val pollMaxOptions: Int, val pollMaxLength: Int, val supportsScheduled: Boolean -) \ No newline at end of file +) + +/** + * Thrown when trying to add an image when video is already present or the other way around + */ +class VideoOrImageException : Exception() \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt index 1fa03d54b..8ff7dcf32 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt @@ -173,7 +173,13 @@ class MediaUploaderImpl( val body = MultipartBody.Part.createFormData("file", filename, fileBody) - val uploadDisposable = mastodonApi.uploadMedia(body) + val description = if (media.description != null) { + MultipartBody.Part.createFormData("description", media.description) + } else { + null + } + + val uploadDisposable = mastodonApi.uploadMedia(body, description) .subscribe({ attachment -> emitter.onNext(UploadEvent.FinishedEvent(attachment)) emitter.onComplete() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt new file mode 100644 index 000000000..6f5f9005a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt @@ -0,0 +1,159 @@ +/* Copyright 2021 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.drafts + +import android.content.Context +import android.net.Uri +import android.util.Log +import android.webkit.MimeTypeMap +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.DraftAttachment +import com.keylesspalace.tusky.db.DraftEntity +import com.keylesspalace.tusky.entity.NewPoll +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.util.IOUtils +import io.reactivex.Completable +import io.reactivex.Single +import io.reactivex.schedulers.Schedulers +import java.io.File +import java.text.SimpleDateFormat +import java.util.* +import javax.inject.Inject + +class DraftHelper @Inject constructor( + val context: Context, + db: AppDatabase +) { + + private val draftDao = db.draftDao() + + fun saveDraft( + draftId: Int, + accountId: Long, + inReplyToId: String?, + content: String?, + contentWarning: String?, + sensitive: Boolean, + visibility: Status.Visibility, + mediaUris: List, + mediaDescriptions: List, + poll: NewPoll?, + failedToSend: Boolean + ): Completable { + return Single.fromCallable { + + val draftDirectory = context.getExternalFilesDir("Tusky") + + if (draftDirectory == null || !(draftDirectory.exists())) { + Log.e("DraftHelper", "Error obtaining directory to save media.") + throw Exception() + } + + val uris = mediaUris.map { uriString -> + uriString.toUri() + }.map { uri -> + if (uri.isNotInFolder(draftDirectory)) { + uri.copyToFolder(draftDirectory) + } else { + uri + } + } + + val types = uris.map { uri -> + val mimeType = context.contentResolver.getType(uri) + when (mimeType?.substring(0, mimeType.indexOf('/'))) { + "video" -> DraftAttachment.Type.VIDEO + "image" -> DraftAttachment.Type.IMAGE + "audio" -> DraftAttachment.Type.AUDIO + else -> throw IllegalStateException("unknown media type") + } + } + + val attachments: MutableList = mutableListOf() + for (i in mediaUris.indices) { + attachments.add( + DraftAttachment( + uriString = uris[i].toString(), + description = mediaDescriptions[i], + type = types[i] + ) + ) + } + + DraftEntity( + id = draftId, + accountId = accountId, + inReplyToId = inReplyToId, + content = content, + contentWarning = contentWarning, + sensitive = sensitive, + visibility = visibility, + attachments = attachments, + poll = poll, + failedToSend = failedToSend + ) + + }.flatMapCompletable { draft -> + draftDao.insertOrReplace(draft) + }.subscribeOn(Schedulers.io()) + } + + fun deleteDraftAndAttachments(draftId: Int): Completable { + return draftDao.find(draftId) + .flatMapCompletable { draft -> + deleteDraftAndAttachments(draft) + } + } + + fun deleteDraftAndAttachments(draft: DraftEntity): Completable { + return deleteAttachments(draft) + .andThen(draftDao.delete(draft.id)) + } + + fun deleteAttachments(draft: DraftEntity): Completable { + return Completable.fromCallable { + draft.attachments.forEach { attachment -> + if (context.contentResolver.delete(attachment.uri, null, null) == 0) { + Log.e("DraftHelper", "Did not delete file ${attachment.uriString}") + } + } + }.subscribeOn(Schedulers.io()) + } + + private fun Uri.isNotInFolder(folder: File): Boolean { + val filePath = path ?: return true + return File(filePath).parentFile == folder + } + + private fun Uri.copyToFolder(folder: File): Uri { + val contentResolver = context.contentResolver + + val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + + val mimeType = contentResolver.getType(this) + val map = MimeTypeMap.getSingleton() + val fileExtension = map.getExtensionFromMimeType(mimeType) + + val filename = String.format("Tusky_Draft_Media_%s.%s", timeStamp, fileExtension) + val file = File(folder, filename) + IOUtils.copyToFile(contentResolver, this, file) + return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt new file mode 100644 index 000000000..69403fdb5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt @@ -0,0 +1,81 @@ +/* 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.components.drafts + +import android.view.ViewGroup +import android.widget.ImageView +import androidx.appcompat.widget.AppCompatImageView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.db.DraftAttachment + +class DraftMediaAdapter( + private val attachmentClick: () -> Unit +) : ListAdapter( + object: DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean { + return oldItem == newItem + } + + } +) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DraftMediaViewHolder { + return DraftMediaViewHolder(AppCompatImageView(parent.context)) + } + + override fun onBindViewHolder(holder: DraftMediaViewHolder, position: Int) { + getItem(position)?.let { attachment -> + if (attachment.type == DraftAttachment.Type.AUDIO) { + holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp) + } else { + Glide.with(holder.itemView.context) + .load(attachment.uri) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .dontAnimate() + .into(holder.imageView) + } + } + } + + inner class DraftMediaViewHolder(val imageView: ImageView) + : RecyclerView.ViewHolder(imageView) { + init { + val thumbnailViewSize = + imageView.context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size) + val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize) + val margin = itemView.context.resources + .getDimensionPixelSize(R.dimen.compose_media_preview_margin) + val marginBottom = itemView.context.resources + .getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom) + layoutParams.setMargins(margin, 0, margin, marginBottom) + imageView.layoutParams = layoutParams + imageView.scaleType = ImageView.ScaleType.CENTER_CROP + imageView.setOnClickListener { + attachmentClick() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt new file mode 100644 index 000000000..ddf8a8385 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt @@ -0,0 +1,197 @@ +/* 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.components.drafts + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.Menu +import android.view.MenuItem +import android.widget.LinearLayout +import android.widget.Toast +import androidx.activity.viewModels +import androidx.lifecycle.Lifecycle +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.SavedTootActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.databinding.ActivityDraftsBinding +import com.keylesspalace.tusky.db.DraftEntity +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.uber.autodispose.android.lifecycle.autoDispose +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import retrofit2.HttpException +import javax.inject.Inject + +class DraftsActivity : BaseActivity(), DraftActionListener { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: DraftsViewModel by viewModels { viewModelFactory } + + private lateinit var binding: ActivityDraftsBinding + private lateinit var bottomSheet: BottomSheetBehavior + + private var oldDraftsButton: MenuItem? = null + + override fun onCreate(savedInstanceState: Bundle?) { + + super.onCreate(savedInstanceState) + + binding = ActivityDraftsBinding.inflate(layoutInflater) + setContentView(binding.root) + + setSupportActionBar(binding.includedToolbar.toolbar) + supportActionBar?.apply { + title = getString(R.string.title_drafts) + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + binding.draftsErrorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_saved_status) + + val adapter = DraftsAdapter(this) + + binding.draftsRecyclerView.adapter = adapter + binding.draftsRecyclerView.layoutManager = LinearLayoutManager(this) + binding.draftsRecyclerView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL)) + + bottomSheet = BottomSheetBehavior.from(binding.bottomSheet.root) + + viewModel.drafts.observe(this) { draftList -> + if (draftList.isEmpty()) { + binding.draftsRecyclerView.hide() + binding.draftsErrorMessageView.show() + } else { + binding.draftsRecyclerView.show() + binding.draftsErrorMessageView.hide() + adapter.submitList(draftList) + } + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.drafts, menu) + oldDraftsButton = menu.findItem(R.id.action_old_drafts) + viewModel.showOldDraftsButton() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe { showOldDraftsButton -> + oldDraftsButton?.isVisible = showOldDraftsButton + } + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressed() + return true + } + R.id.action_old_drafts -> { + val intent = Intent(this, SavedTootActivity::class.java) + startActivityWithSlideInAnimation(intent) + return true + } + } + return super.onOptionsItemSelected(item) + } + + override fun onOpenDraft(draft: DraftEntity) { + + if (draft.inReplyToId != null) { + bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED + viewModel.getToot(draft.inReplyToId) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this) + .subscribe({ status -> + val composeOptions = ComposeActivity.ComposeOptions( + draftId = draft.id, + tootText = draft.content, + contentWarning = draft.contentWarning, + inReplyToId = draft.inReplyToId, + replyingStatusContent = status.content.toString(), + replyingStatusAuthor = status.account.localUsername, + draftAttachments = draft.attachments, + poll = draft.poll, + sensitive = draft.sensitive, + visibility = draft.visibility + ) + + bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN + + startActivity(ComposeActivity.startIntent(this, composeOptions)) + + }, { throwable -> + + bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN + + Log.w(TAG, "failed loading reply information", throwable) + + if (throwable is HttpException && throwable.code() == 404) { + // the original status to which a reply was drafted has been deleted + // let's open the ComposeActivity without reply information + Toast.makeText(this, getString(R.string.drafts_toot_reply_removed), Toast.LENGTH_LONG).show() + openDraftWithoutReply(draft) + } else { + Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT) + .show() + } + }) + } else { + openDraftWithoutReply(draft) + } + } + + private fun openDraftWithoutReply(draft: DraftEntity) { + val composeOptions = ComposeActivity.ComposeOptions( + draftId = draft.id, + tootText = draft.content, + contentWarning = draft.contentWarning, + draftAttachments = draft.attachments, + poll = draft.poll, + sensitive = draft.sensitive, + visibility = draft.visibility + ) + + startActivity(ComposeActivity.startIntent(this, composeOptions)) + } + + override fun onDeleteDraft(draft: DraftEntity) { + viewModel.deleteDraft(draft) + Snackbar.make(binding.root, getString(R.string.draft_deleted), Snackbar.LENGTH_LONG) + .setAction(R.string.action_undo) { + viewModel.restoreDraft(draft) + } + .show() + } + + companion object { + const val TAG = "DraftsActivity" + + fun newIntent(context: Context) = Intent(context, DraftsActivity::class.java) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt new file mode 100644 index 000000000..5dfbceac8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt @@ -0,0 +1,92 @@ +/* Copyright 2021 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.drafts + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagedListAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.databinding.ItemDraftBinding +import com.keylesspalace.tusky.db.DraftEntity +import com.keylesspalace.tusky.util.BindingViewHolder +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.visible + +interface DraftActionListener { + fun onOpenDraft(draft: DraftEntity) + fun onDeleteDraft(draft: DraftEntity) +} + +class DraftsAdapter( + private val listener: DraftActionListener +) : PagedListAdapter>( + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean { + return oldItem == newItem + } + } +) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingViewHolder { + + val binding = ItemDraftBinding.inflate(LayoutInflater.from(parent.context), parent, false) + + val viewHolder = BindingViewHolder(binding) + + binding.draftMediaPreview.layoutManager = LinearLayoutManager(binding.root.context, RecyclerView.HORIZONTAL, false) + binding.draftMediaPreview.adapter = DraftMediaAdapter { + getItem(viewHolder.adapterPosition)?.let { draft -> + listener.onOpenDraft(draft) + } + } + + return viewHolder + } + + override fun onBindViewHolder(holder: BindingViewHolder, position: Int) { + getItem(position)?.let { draft -> + holder.binding.root.setOnClickListener { + listener.onOpenDraft(draft) + } + holder.binding.deleteButton.setOnClickListener { + listener.onDeleteDraft(draft) + } + holder.binding.draftSendingInfo.visible(draft.failedToSend) + + holder.binding.contentWarning.visible(!draft.contentWarning.isNullOrEmpty()) + holder.binding.contentWarning.text = draft.contentWarning + holder.binding.content.text = draft.content + + holder.binding.draftMediaPreview.visible(draft.attachments.isNotEmpty()) + (holder.binding.draftMediaPreview.adapter as DraftMediaAdapter).submitList(draft.attachments) + + if (draft.poll != null) { + holder.binding.draftPoll.show() + holder.binding.draftPoll.setPoll(draft.poll) + } else { + holder.binding.draftPoll.hide() + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt new file mode 100644 index 000000000..9eca963aa --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt @@ -0,0 +1,69 @@ +/* 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.components.drafts + +import androidx.lifecycle.ViewModel +import androidx.paging.toLiveData +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.DraftEntity +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import io.reactivex.Observable +import io.reactivex.Single +import javax.inject.Inject + +class DraftsViewModel @Inject constructor( + val database: AppDatabase, + val accountManager: AccountManager, + val api: MastodonApi, + val draftHelper: DraftHelper +) : ViewModel() { + + val drafts = database.draftDao().loadDrafts(accountManager.activeAccount?.id!!).toLiveData(pageSize = 20) + + private val deletedDrafts: MutableList = mutableListOf() + + fun showOldDraftsButton(): Observable { + return database.tootDao().savedTootCount() + .map { count -> count > 0 } + } + + fun deleteDraft(draft: DraftEntity) { + // this does not immediately delete media files to avoid unnecessary file operations + // in case the user decides to restore the draft + database.draftDao().delete(draft.id) + .subscribe() + deletedDrafts.add(draft) + } + + fun restoreDraft(draft: DraftEntity) { + database.draftDao().insertOrReplace(draft) + .subscribe() + deletedDrafts.remove(draft) + } + + fun getToot(tootId: String): Single { + return api.statusSingle(tootId) + } + + override fun onCleared() { + deletedDrafts.forEach { + draftHelper.deleteAttachments(it).subscribe() + } + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootActivity.kt index 18b04df52..f0944a347 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledTootActivity.kt @@ -120,7 +120,7 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec override fun edit(item: ScheduledStatus) { val intent = ComposeActivity.startIntent(this, ComposeActivity.ComposeOptions( - scheduledTootUid = item.id, + scheduledTootId = item.id, tootText = item.params.text, contentWarning = item.params.spoilerText, mediaAttachments = item.mediaAttachments, 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 5c5b7cb62..d35fd3891 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -28,9 +28,9 @@ import com.keylesspalace.tusky.components.conversation.ConversationEntity; * DB version & declare DAO */ -@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, +@Database(entities = { TootEntity.class, DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, ConversationEntity.class - }, version = 24) + }, version = 25) public abstract class AppDatabase extends RoomDatabase { public abstract TootDao tootDao(); @@ -38,6 +38,7 @@ public abstract class AppDatabase extends RoomDatabase { public abstract InstanceDao instanceDao(); public abstract ConversationsDao conversationDao(); public abstract TimelineDao timelineDao(); + public abstract DraftDao draftDao(); public static final Migration MIGRATION_2_3 = new Migration(2, 3) { @Override @@ -46,7 +47,6 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("INSERT INTO TootEntity2 SELECT * FROM TootEntity;"); database.execSQL("DROP TABLE TootEntity;"); database.execSQL("ALTER TABLE TootEntity2 RENAME TO TootEntity;"); - } }; @@ -347,4 +347,22 @@ public abstract class AppDatabase extends RoomDatabase { } }; + public static final Migration MIGRATION_24_25 = new Migration(24, 25) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL( + "CREATE TABLE IF NOT EXISTS `DraftEntity` (" + + "`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)" + ); + } + }; } 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 3492deda8..1b1f94f3d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -24,10 +24,7 @@ import com.google.gson.reflect.TypeToken import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity import com.keylesspalace.tusky.createTabDataFromId -import com.keylesspalace.tusky.entity.Attachment -import com.keylesspalace.tusky.entity.Emoji -import com.keylesspalace.tusky.entity.Poll -import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.* import com.keylesspalace.tusky.json.SpannedTypeAdapter import com.keylesspalace.tusky.util.trimTrailingWhitespace import java.net.URLDecoder @@ -151,4 +148,23 @@ class Converters { return gson.fromJson(pollJson, Poll::class.java) } -} \ No newline at end of file + @TypeConverter + fun newPollToJson(newPoll: NewPoll?): String? { + return gson.toJson(newPoll) + } + + @TypeConverter + fun jsonToNewPoll(newPollJson: String?): NewPoll? { + return gson.fromJson(newPollJson, NewPoll::class.java) + } + + @TypeConverter + fun draftAttachmentListToJson(draftAttachments: List?): String? { + return gson.toJson(draftAttachments) + } + + @TypeConverter + fun jsonToDraftAttachmentList(draftAttachmentListJson: String?): List? { + return gson.fromJson(draftAttachmentListJson, object : TypeToken>() {}.type) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt new file mode 100644 index 000000000..105fd7c5a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt @@ -0,0 +1,40 @@ +/* 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.db + +import androidx.paging.DataSource +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.reactivex.Completable +import io.reactivex.Single + +@Dao +interface DraftDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOrReplace(draft: DraftEntity): Completable + + @Query("SELECT * FROM DraftEntity WHERE accountId = :accountId ORDER BY id ASC") + fun loadDrafts(accountId: Long): DataSource.Factory + + @Query("DELETE FROM DraftEntity WHERE id = :id") + fun delete(id: Int): Completable + + @Query("SELECT * FROM DraftEntity WHERE id = :id") + fun find(id: Int): Single +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt new file mode 100644 index 000000000..be1eca589 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt @@ -0,0 +1,55 @@ +/* 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.db + +import android.net.Uri +import android.os.Parcelable +import androidx.core.net.toUri +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import com.keylesspalace.tusky.entity.NewPoll +import com.keylesspalace.tusky.entity.Status +import kotlinx.android.parcel.Parcelize + +@Entity +@TypeConverters(Converters::class) +data class DraftEntity( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + val accountId: Long, + val inReplyToId: String?, + val content: String?, + val contentWarning: String?, + val sensitive: Boolean, + val visibility: Status.Visibility, + val attachments: List, + val poll: NewPoll?, + val failedToSend: Boolean +) + +@Parcelize +data class DraftAttachment( + val uriString: String, + val description: String?, + val type: Type +): Parcelable { + val uri: Uri + get() = uriString.toUri() + + enum class Type { + IMAGE, VIDEO, AUDIO; + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt index da98cb4b3..296111d30 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -26,7 +26,7 @@ import com.keylesspalace.tusky.entity.Status // Avoiding rescanning status table when accounts table changes. Recommended by Room(c). indices = [Index("authorServerId", "timelineUserId")] ) -@TypeConverters(TootEntity.Converters::class) +@TypeConverters(Converters::class) data class TimelineStatusEntity( val serverId: String, // id never flips: we need it for sorting so it's a real id val url: String?, diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TootDao.java b/app/src/main/java/com/keylesspalace/tusky/db/TootDao.java index c121e1705..f46c2753a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TootDao.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/TootDao.java @@ -16,12 +16,12 @@ package com.keylesspalace.tusky.db; import androidx.room.Dao; -import androidx.room.Insert; -import androidx.room.OnConflictStrategy; import androidx.room.Query; import java.util.List; +import io.reactivex.Observable; + /** * Created by cto3543 on 28/06/2017. * @@ -30,8 +30,6 @@ import java.util.List; @Dao public interface TootDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - void insertOrReplace(TootEntity users); @Query("SELECT * FROM TootEntity ORDER BY uid DESC") List loadAll(); @@ -41,4 +39,7 @@ public interface TootDao { @Query("SELECT * FROM TootEntity WHERE uid = :uid") TootEntity find(int uid); -} + + @Query("SELECT COUNT(*) FROM TootEntity") + Observable savedTootCount(); +} \ No newline at end of file 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 0257c28f6..2e82d6407 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -18,6 +18,7 @@ package com.keylesspalace.tusky.di import com.keylesspalace.tusky.* import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.components.drafts.DraftsActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.report.ReportActivity @@ -107,4 +108,7 @@ abstract class ActivitiesModule { @ContributesAndroidInjector abstract fun contributesAnnouncementsActivity(): AnnouncementsActivity + + @ContributesAndroidInjector + abstract fun contributesDraftActivity(): DraftsActivity } 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 7e86bbac5..1137b12b0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -80,7 +80,7 @@ class AppModule { AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15, AppDatabase.MIGRATION_15_16, AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19, AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22, - AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24) + AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index c461012db..ce83deda8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModelProvider import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.conversation.ConversationsViewModel +import com.keylesspalace.tusky.components.drafts.DraftsViewModel import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.scheduled.ScheduledTootViewModel import com.keylesspalace.tusky.components.search.SearchViewModel @@ -91,5 +92,10 @@ abstract class ViewModelModule { @ViewModelKey(AnnouncementsViewModel::class) internal abstract fun announcementsViewModel(viewModel: AnnouncementsViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(DraftsViewModel::class) + internal abstract fun draftsViewModel(viewModel: DraftsViewModel): ViewModel + //Add more ViewModels here } 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 7b79e1b54..58caec85e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -124,7 +124,8 @@ interface MastodonApi { @Multipart @POST("api/v1/media") fun uploadMedia( - @Part file: MultipartBody.Part + @Part file: MultipartBody.Part, + @Part description: MultipartBody.Part? = null ): Single @FormUrlEncoded @@ -147,6 +148,11 @@ interface MastodonApi { @Path("id") statusId: String ): Call + @GET("api/v1/statuses/{id}") + fun statusSingle( + @Path("id") statusId: String + ): Single + @GET("api/v1/statuses/{id}/context") fun statusContext( @Path("id") statusId: String diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index f8bf81b30..911f58c1b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -60,7 +60,6 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { val notificationManager = NotificationManagerCompat.from(context) - if (intent.action == NotificationHelper.REPLY_ACTION) { val message = getReplyMessage(intent) @@ -89,22 +88,23 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { val sendIntent = SendTootService.sendTootIntent( context, TootToSend( - text, - spoiler, - visibility.serverString(), - false, - emptyList(), - emptyList(), - emptyList(), - null, - citedStatusId, - null, - null, - null, - null, account.id, - 0, - randomAlphanumericString(16), - 0 + text = text, + warningText = spoiler, + visibility = visibility.serverString(), + sensitive = false, + mediaIds = emptyList(), + mediaUris = emptyList(), + mediaDescriptions = emptyList(), + scheduledAt = null, + inReplyToId = citedStatusId, + poll = null, + replyingStatusContent = null, + replyingStatusAuthorUsername = null, + accountId = account.id, + savedTootUid = -1, + draftId = -1, + idempotencyKey = randomAlphanumericString(16), + retries = 0 ) ) diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt index 328265b5e..b54a941b1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt @@ -18,6 +18,7 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusScheduledEvent +import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.di.Injectable @@ -46,7 +47,8 @@ class SendTootService : Service(), Injectable { lateinit var eventHub: EventHub @Inject lateinit var database: AppDatabase - + @Inject + lateinit var draftHelper: DraftHelper @Inject lateinit var saveTootHelper: SaveTootHelper @@ -163,6 +165,10 @@ class SendTootService : Service(), Injectable { if (tootToSend.savedTootUid != 0) { saveTootHelper.deleteDraft(tootToSend.savedTootUid) } + if (tootToSend.draftId != 0) { + draftHelper.deleteDraftAndAttachments(tootToSend.draftId) + .subscribe() + } if (scheduled) { response.body()?.let(::StatusScheduledEvent)?.let(eventHub::dispatch) @@ -245,17 +251,19 @@ class SendTootService : Service(), Injectable { private fun saveTootToDrafts(toot: TootToSend) { - saveTootHelper.saveToot(toot.text, - toot.warningText, - toot.savedJsonUrls, - toot.mediaUris, - toot.mediaDescriptions, - toot.savedTootUid, - toot.inReplyToId, - toot.replyingStatusContent, - toot.replyingStatusAuthorUsername, - Status.Visibility.byString(toot.visibility), - toot.poll) + draftHelper.saveDraft( + draftId = toot.draftId, + accountId = toot.accountId, + inReplyToId = toot.inReplyToId, + content = toot.text, + contentWarning = toot.warningText, + sensitive = toot.sensitive, + visibility = Status.Visibility.byString(toot.visibility), + mediaUris = toot.mediaUris, + mediaDescriptions = toot.mediaDescriptions, + poll = toot.poll, + failedToSend = true + ).subscribe() } private fun cancelSendingIntent(tootId: Int): PendingIntent { @@ -323,9 +331,9 @@ data class TootToSend( val poll: NewPoll?, val replyingStatusContent: String?, val replyingStatusAuthorUsername: String?, - val savedJsonUrls: List?, val accountId: Long, val savedTootUid: Int, + val draftId: Int, val idempotencyKey: String, var retries: Int ) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/util/BindingViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/util/BindingViewHolder.kt new file mode 100644 index 000000000..14aee81b5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/BindingViewHolder.kt @@ -0,0 +1,8 @@ +package com.keylesspalace.tusky.util + +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding + +class BindingViewHolder( + val binding: T +) : RecyclerView.ViewHolder(binding.root) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/RxAwareViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/util/RxAwareViewModel.kt index 2ad4b825d..c78b0f787 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/RxAwareViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/RxAwareViewModel.kt @@ -1,5 +1,6 @@ package com.keylesspalace.tusky.util +import androidx.annotation.CallSuper import androidx.lifecycle.ViewModel import io.reactivex.disposables.CompositeDisposable import io.reactivex.disposables.Disposable @@ -9,6 +10,7 @@ open class RxAwareViewModel : ViewModel() { fun Disposable.autoDispose() = disposables.add(this) + @CallSuper override fun onCleared() { super.onCleared() disposables.clear() diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java index 690098309..29693550d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java @@ -1,33 +1,18 @@ package com.keylesspalace.tusky.util; -import android.annotation.SuppressLint; -import android.content.ContentResolver; import android.content.Context; import android.net.Uri; -import android.os.AsyncTask; -import android.text.TextUtils; import android.util.Log; -import android.webkit.MimeTypeMap; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.FileProvider; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; -import com.keylesspalace.tusky.BuildConfig; import com.keylesspalace.tusky.db.AppDatabase; import com.keylesspalace.tusky.db.TootDao; import com.keylesspalace.tusky.db.TootEntity; -import com.keylesspalace.tusky.entity.NewPoll; -import com.keylesspalace.tusky.entity.Status; -import java.io.File; -import java.text.SimpleDateFormat; import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.Locale; import javax.inject.Inject; @@ -45,61 +30,6 @@ public final class SaveTootHelper { this.context = context; } - @SuppressLint("StaticFieldLeak") - public boolean saveToot(@NonNull String content, - @NonNull String contentWarning, - @Nullable List savedJsonUrls, - @NonNull List mediaUris, - @NonNull List mediaDescriptions, - int savedTootUid, - @Nullable String inReplyToId, - @Nullable String replyingStatusContent, - @Nullable String replyingStatusAuthorUsername, - @NonNull Status.Visibility statusVisibility, - @Nullable NewPoll poll) { - - if (TextUtils.isEmpty(content) && mediaUris.isEmpty() && poll == null) { - return false; - } - - // Get any existing file's URIs. - - String mediaUrlsSerialized = null; - String mediaDescriptionsSerialized = null; - - if (!ListUtils.isEmpty(mediaUris)) { - List savedList = saveMedia(mediaUris, savedJsonUrls); - if (!ListUtils.isEmpty(savedList)) { - mediaUrlsSerialized = gson.toJson(savedList); - if (!ListUtils.isEmpty(savedJsonUrls)) { - deleteMedia(setDifference(savedJsonUrls, savedList)); - } - } else { - return false; - } - mediaDescriptionsSerialized = gson.toJson(mediaDescriptions); - } else if (!ListUtils.isEmpty(savedJsonUrls)) { - /* If there were URIs in the previous draft, but they've now been removed, those files - * can be deleted. */ - deleteMedia(savedJsonUrls); - } - final TootEntity toot = new TootEntity(savedTootUid, content, mediaUrlsSerialized, mediaDescriptionsSerialized, contentWarning, - inReplyToId, - replyingStatusContent, - replyingStatusAuthorUsername, - statusVisibility, - poll); - - new AsyncTask() { - @Override - protected Void doInBackground(Void... params) { - tootDao.insertOrReplace(toot); - return null; - } - }.execute(); - return true; - } - public void deleteDraft(int tootId) { TootEntity item = tootDao.find(tootId); if (item != null) { @@ -124,82 +54,4 @@ public final class SaveTootHelper { tootDao.delete(item.getUid()); } - @Nullable - private List saveMedia(@NonNull List mediaUris, - @Nullable List existingUris) { - - File directory = context.getExternalFilesDir("Tusky"); - - if (directory == null || !(directory.exists())) { - Log.e(TAG, "Error obtaining directory to save media."); - return null; - } - - ContentResolver contentResolver = context.getContentResolver(); - ArrayList filesSoFar = new ArrayList<>(); - ArrayList results = new ArrayList<>(); - for (String mediaUri : mediaUris) { - /* If the media was already saved in a previous draft, there's no need to save another - * copy, just add the existing URI to the results. */ - if (existingUris != null) { - int index = existingUris.indexOf(mediaUri); - if (index != -1) { - results.add(mediaUri); - continue; - } - } - // Otherwise, save the media. - - Uri uri = Uri.parse(mediaUri); - - String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()); - - String mimeType = contentResolver.getType(uri); - MimeTypeMap map = MimeTypeMap.getSingleton(); - String fileExtension = map.getExtensionFromMimeType(mimeType); - String filename = String.format("Tusky_Draft_Media_%s.%s", timeStamp, fileExtension); - File file = new File(directory, filename); - filesSoFar.add(file); - boolean copied = IOUtils.copyToFile(contentResolver, uri, file); - if (!copied) { - /* If any media files were created in prior iterations, delete those before - * returning. */ - for (File earlierFile : filesSoFar) { - boolean deleted = earlierFile.delete(); - if (!deleted) { - Log.i(TAG, "Could not delete the file " + earlierFile.toString()); - } - } - return null; - } - Uri resultUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file); - results.add(resultUri.toString()); - } - return results; - } - - private void deleteMedia(List mediaUris) { - for (String uriString : mediaUris) { - Uri uri = Uri.parse(uriString); - if (context.getContentResolver().delete(uri, null, null) == 0) { - Log.e(TAG, String.format("Did not delete file %s.", uriString)); - } - } - } - - /** - * A∖B={x∈A|x∉B} - * - * @return all elements of set A that are not in set B. - */ - private static List setDifference(List a, List b) { - List c = new ArrayList<>(); - for (String s : a) { - if (!b.contains(s)) { - c.add(s); - } - } - return c; - } - -} +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_alert_circle.xml b/app/src/main/res/drawable/ic_alert_circle.xml new file mode 100644 index 000000000..4c894f0dc --- /dev/null +++ b/app/src/main/res/drawable/ic_alert_circle.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_notebook.xml b/app/src/main/res/drawable/ic_notebook.xml index 2395cd141..93ff78919 100644 --- a/app/src/main/res/drawable/ic_notebook.xml +++ b/app/src/main/res/drawable/ic_notebook.xml @@ -4,5 +4,5 @@ android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"> - + \ No newline at end of file diff --git a/app/src/main/res/layout-sw640dp/fragment_view_thread.xml b/app/src/main/res/layout-sw640dp/fragment_view_thread.xml index 52de2b95a..150f0860c 100644 --- a/app/src/main/res/layout-sw640dp/fragment_view_thread.xml +++ b/app/src/main/res/layout-sw640dp/fragment_view_thread.xml @@ -1,8 +1,10 @@ + android:background="?attr/windowBackgroundColor" + tools:viewBindingIgnore="true"> + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_view_thread.xml b/app/src/main/res/layout/fragment_view_thread.xml index 433f1ed0f..806d420a4 100644 --- a/app/src/main/res/layout/fragment_view_thread.xml +++ b/app/src/main/res/layout/fragment_view_thread.xml @@ -1,9 +1,11 @@ + android:layout_gravity="top" + tools:viewBindingIgnore="true"> + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/toolbar_basic.xml b/app/src/main/res/layout/toolbar_basic.xml index 71039105f..47bd2d90f 100644 --- a/app/src/main/res/layout/toolbar_basic.xml +++ b/app/src/main/res/layout/toolbar_basic.xml @@ -1,19 +1,16 @@ - + - + android:layout_height="?attr/actionBarSize" /> - - - - - \ No newline at end of file + diff --git a/app/src/main/res/menu/drafts.xml b/app/src/main/res/menu/drafts.xml new file mode 100644 index 000000000..bbc9202f4 --- /dev/null +++ b/app/src/main/res/menu/drafts.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 835a32f8e..6f4b1cee7 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -36,7 +36,7 @@ الحسابات المحظورة طلبات المتابعة عدل ملفك التعريفي - المسودات + المسودات الرّخص \@%s شارَكَه %s diff --git a/app/src/main/res/values-ber/strings.xml b/app/src/main/res/values-ber/strings.xml index 2e0412ca4..9726d4ff0 100644 --- a/app/src/main/res/values-ber/strings.xml +++ b/app/src/main/res/values-ber/strings.xml @@ -16,7 +16,7 @@ ⵏⴰⴸⵉ ⵣⵔⴻⴳ ⴰⵎⴰⵖⵏⵓ ⴼⴼⴻⵖ - ⵉⵔⴻⵡⵡⴰⵢⴻⵏ + ⵉⵔⴻⵡⵡⴰⵢⴻⵏ ⵉⵙⵎⴻⵏⵢⵉⴼⴻⵏ ⵓⵖⴰⵍ ⴽⴻⵎⵎⴻⵍ diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml index 2413319b5..634f0d8f3 100644 --- a/app/src/main/res/values-bn-rBD/strings.xml +++ b/app/src/main/res/values-bn-rBD/strings.xml @@ -292,7 +292,7 @@ মিডিয়া লুকানো সংবেদনশীল কন্টেন্ট লাইসেন্সগুলি - খসড়াগুলো + খসড়াগুলো আপনার প্রোফাইল সম্পাদনা করুন অনুরোধ অনুসরণ করুন অবরুদ্ধ ব্যবহারকারী diff --git a/app/src/main/res/values-bn-rIN/strings.xml b/app/src/main/res/values-bn-rIN/strings.xml index e578655ad..ae3ae2f21 100644 --- a/app/src/main/res/values-bn-rIN/strings.xml +++ b/app/src/main/res/values-bn-rIN/strings.xml @@ -36,7 +36,7 @@ অবরুদ্ধ ব্যবহারকারী অনুরোধ অনুসরণ করুন আপনার প্রোফাইল সম্পাদনা করুন - খসড়াগুলো + খসড়াগুলো লাইসেন্সগুলি \@%s %s সমর্থন দিয়েছে diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index a769dbe80..9748a4819 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -29,7 +29,7 @@ Usuaris blocats Peticions de seguiment Edita el perfil - Esborranys + Esborranys \@%s %s tootejat Contingut sensible diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 867b3ec2c..bac6fe1a6 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -36,7 +36,7 @@ Blokovaní uživatelé Žádosti o sledování Upravit váš profil - Koncepty + Koncepty Licence \@%s %s boostnul/a diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml index a322a5df6..b41bbd6f9 100644 --- a/app/src/main/res/values-cy/strings.xml +++ b/app/src/main/res/values-cy/strings.xml @@ -32,7 +32,7 @@ Defnyddwyr wedi\'u blocio Dilyn ceisiadau Golygu\'ch Proffil - Drafftiau + Drafftiau Trwyddedau %s wedi\'u hybu Cynnwys sensitif diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6218bbbfe..decfff34a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -36,7 +36,7 @@ Blockierte Profile Folgeanfragen Dein Profil bearbeiten - Entwürfe + Entwürfe Lizenzen \@%s %s teilte diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index e2e011087..34b5a5bc3 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -36,7 +36,7 @@ Blokitaj uzantoj Petoj de sekvado Redakti vian profilon - Malnetoj + Malnetoj Permesiloj \@%s %s diskonigis diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 089552800..2b2633a62 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -36,7 +36,7 @@ Bloqueados Solicitudes Editar tu perfil - Borradores + Borradores Licencias \@%s %s compartió diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index f4eb9e71c..0c6169582 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -32,7 +32,7 @@ Blokeatuak Eskakizunak Profila editatu - Zirriborroak + Zirriborroak Lizentziak %s-(e)k bultzatu du Kontuz edukiarekin diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 2f20b2109..2d5f570e4 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -32,7 +32,7 @@ کاربران مسدود درخواست‌های پی‌گیری ویرایش نمایه‌تان - پیش‌نویس‌ها + پیش‌نویس‌ها پروانه‌ها %s تقویت کرد محتوای حسّاس diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6240d8134..74f5e1055 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -36,7 +36,7 @@ Comptes bloqués Demandes d’abonnement Modifier votre profil - Brouillons + Brouillons Licences \@%s %s a partagé diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index aad2a612e..130f4abef 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -177,7 +177,7 @@ Roghanna Cuntais Sainroghanna Logáil Amach - Dréachtaí + Dréachtaí Roghaí Theip ar fhíordheimhniú leis an gcás sin. Cad is sampla ann\? diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index ed594dc7e..83bfce331 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -8,7 +8,7 @@ Roighainnean cunntais Roighainnean Clàraich a-mach - Dreachd + Dreachd Prìomhaich Dè a th ’ann an àite\? Deasaich diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index a4c1b373b..20b6e5d0b 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -2,7 +2,7 @@ हिंदी पसंदीदा - प्रारूप + प्रारूप लॉग आउट पसंद खाता प्राथमिकताएं diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 9215ed09c..0086e2991 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -36,7 +36,7 @@ Letiltott felhasználók Követési kérelmek Profilod szerkesztése - Piszkozatok + Piszkozatok Licenszek \@%s %s megtolta diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index e52cda276..7f911d96d 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -3,7 +3,7 @@ Skrá inn með Mastodon Hvað er tilvik\? Eftirlæti - Drög + Drög Skrá út Kjörstillingar Eiginleikar tengingar diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 3b716a1f9..c43e60f4d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -36,7 +36,7 @@ Utenti bloccati Richieste di seguirti Modifica il tuo profilo - Bozze + Bozze Licenze \@%s %s ha boostato diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index df4ec9319..a716f733b 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -35,7 +35,7 @@ ブロックしたユーザー フォローリクエスト プロフィールを編集 - 下書き + 下書き ライセンス %sさんがブーストしました 閲覧注意 diff --git a/app/src/main/res/values-kab/strings.xml b/app/src/main/res/values-kab/strings.xml index 8f51de5e5..ad5a99d92 100644 --- a/app/src/main/res/values-kab/strings.xml +++ b/app/src/main/res/values-kab/strings.xml @@ -2,7 +2,7 @@ Qqen ɣer Maṣṭudun Ismenyifen - Irewwayen + Irewwayen Ffeɣ Iɣewwaṛen Iɣewwaṛen n umiḍan diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index cf11c797f..893b4acb1 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -37,7 +37,7 @@ 숨긴 도메인 팔로우 요청 프로필 편집 - 임시 저장 + 임시 저장 라이선스 \@%s %s님이 부스트 했습니다 diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml index 72119eef6..de97414b7 100644 --- a/app/src/main/res/values-ml/strings.xml +++ b/app/src/main/res/values-ml/strings.xml @@ -3,7 +3,7 @@ മസ്റ്റഡോൺ വഴി പ്രവേശിക്കുക എന്താണ് ഒരു ഇൻസ്റ്റൻസ്\? പ്രിയപ്പെട്ടവ - കരടുകൾ + കരടുകൾ പുറത്തിറങ്ങുക മുൻഗണനകൾ അക്കൗണ്ട് മുൻഗണനകൾ diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index b8d2d09da..50d94b3d7 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -36,7 +36,7 @@ Geblokkeerde gebruikers Volgverzoeken Profiel bewerken - Concepten + Concepten Licenties \@%s %s boostte diff --git a/app/src/main/res/values-no-rNB/strings.xml b/app/src/main/res/values-no-rNB/strings.xml index cd633dd0e..3adcf1eb8 100644 --- a/app/src/main/res/values-no-rNB/strings.xml +++ b/app/src/main/res/values-no-rNB/strings.xml @@ -36,7 +36,7 @@ Blokkerte brukere Forespørsler om følgen Endre profilen din - Kladder + Kladder Lisenser \@%s %s boosted diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml index c22afc29d..a404fef52 100644 --- a/app/src/main/res/values-oc/strings.xml +++ b/app/src/main/res/values-oc/strings.xml @@ -31,7 +31,7 @@ Utilizaires blocats Demandas d’abonament Modificar lo perfil - Borrolhons + Borrolhons Licéncias %s partejat Contengut sensible diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 9082f6347..03ca93804 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -31,7 +31,7 @@ Zablokowani użytkownicy Prośby o możliwość śledzenia Edytuj profil - Szkice + Szkice Licencje %s podbił Wrażliwe treści diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 95bf8a29e..d36363808 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -34,7 +34,7 @@ Usuários bloqueados Seguidores pendentes Editar perfil - Rascunhos + Rascunhos Licenças %s deu boost Mídia sensível diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 6adb5768f..9e2cf800f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -36,7 +36,7 @@ Список блокировки Запросы на подписку Редактировать профиль - Черновики + Черновики Лицензии \@%s %s продвинул(а) diff --git a/app/src/main/res/values-sa/strings.xml b/app/src/main/res/values-sa/strings.xml index 2fb217ade..18bb76015 100644 --- a/app/src/main/res/values-sa/strings.xml +++ b/app/src/main/res/values-sa/strings.xml @@ -36,7 +36,7 @@ \@%s अनुज्ञापत्राणि कालबद्धदौत्यानि - लेखविकर्षाः + लेखविकर्षाः स्वीयव्यक्तिविवरणं सम्पाद्यताम् अनुसरणार्थमनुरोधाः प्रच्छन्नप्रदेशाः diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 68c278706..14af6584d 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -1,7 +1,7 @@ Prihlásiť sa účtom Mastodon - Koncepty + Koncepty Odhlásiť sa Nastavenia Nastavenia účtu diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 70bb80beb..35b3f08ea 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -34,7 +34,7 @@ Blokirani uporabniki Zahteve za Sledenje Uredi svoj profil - Osnutki + Osnutki Licence \@%s Občutljiva vsebina diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index ff8a5a133..34eb3e8c1 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -36,7 +36,7 @@ Blockerade användare Följarförfrågningar Ändra din profil - Utkast + Utkast Licenser \@%s %s knuffade diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 2affaa29b..d726ba321 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -29,7 +29,7 @@ தடைசெய்யபட்ட பயனர்கள் பின்பற்ற கோரிக்கை சுயவிவரத்தை திருத்த - வரைவுகள் + வரைவுகள் %s மேலேற்றப்பட்டது உணர்ச்சிகரமான உள்ளடக்கம் ஊடகம் மறைக்கப்பட்டது diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 5c994db6a..dc5487a81 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -431,7 +431,7 @@ ตั้งค่าบัญชี ตั้งค่า ออกจากระบบ - ฉบับร่าง + ฉบับร่าง ชื่นชอบ การยืนยันตัวตนกับเซิร์ฟเวอร์นั้นล้มเหลว Instance คือ\? diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 596bafa41..684ecc21e 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -36,7 +36,7 @@ Engellenmiş kullanıcılar Takip Etme İstekleri Profili düzeltme - Taslaklar + Taslaklar Lisanslar \@%s %s yineledi diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index cb7869add..5c49c828c 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -37,7 +37,7 @@ Налаштування акаунта Налаштування Вийти - Чернетки + Чернетки Вподобане Увійти Зʼєднання… diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 460ebb2d8..d785a1f1a 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -197,7 +197,7 @@ Cộng đồng Thông báo Bảng tin - Nháp + Nháp Lượt thích Máy chủ là gì\? Tải xem trước hình ảnh diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 03c70771a..e123fd9e5 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -36,7 +36,7 @@ 被屏蔽的用户 关注请求 编辑个人资料 - 草稿 + 草稿 开源协议 \@%s %s 转嘟了 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 91bed1d4c..5c50933d4 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -36,7 +36,7 @@ 被封鎖的使用者 關注請求 編輯個人資料 - 草稿 + 草稿 開源授權 \@%s %s 轉嘟了 diff --git a/app/src/main/res/values-zh-rMO/strings.xml b/app/src/main/res/values-zh-rMO/strings.xml index 8333ff691..538284c60 100644 --- a/app/src/main/res/values-zh-rMO/strings.xml +++ b/app/src/main/res/values-zh-rMO/strings.xml @@ -36,7 +36,7 @@ 被封鎖的使用者 關注請求 編輯個人資料 - 草稿 + 草稿 開源授權 \@%s %s 轉嘟了 diff --git a/app/src/main/res/values-zh-rSG/strings.xml b/app/src/main/res/values-zh-rSG/strings.xml index 7fe476a6d..2f45c629d 100644 --- a/app/src/main/res/values-zh-rSG/strings.xml +++ b/app/src/main/res/values-zh-rSG/strings.xml @@ -36,7 +36,7 @@ 被屏蔽的用户 关注请求 编辑个人资料 - 草稿 + 草稿 开源协议 \@%s %s 转嘟了 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index f4e93299d..6d690ace9 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -36,7 +36,7 @@ 被封鎖的使用者 關注請求 編輯個人資料 - 草稿 + 草稿 開源授權 \@%s %s 轉嘟了 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index dd4f599ca..5ec69307a 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -45,6 +45,7 @@ 5dp 12dp + 120dp 72dp 108dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6b08043e5..7439808b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -40,7 +40,7 @@ Hidden domains Follow Requests Edit your profile - Drafts + Drafts Scheduled toots Announcements Licenses @@ -585,6 +585,7 @@ Wellbeing Your private note about this account Saved! + Some information that might affect your mental wellbeing will be hidden. This includes:\n\n - Favorite/Boost/Follow notifications\n - Favorite/Boost count on toots\n @@ -598,4 +599,15 @@ You cannot upload more than %1$d media attachments. Do you really want to delete the list %s? + This toot failed to send! + + + The draft feature in Tusky has been completely redesigned to be faster, more user friendly and less buggy.\n + You can still access your old drafts via a button on the new drafts screen, + but they will be removed in a future update! + + Old Drafts + Failed loading Reply information + Draft deleted + The Toot you drafted a reply to has been removed diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt index 7f33a9769..bd9be3b21 100644 --- a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt @@ -13,7 +13,6 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ - package com.keylesspalace.tusky import android.content.Intent @@ -25,6 +24,7 @@ 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.MediaUploader +import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.db.* import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Account @@ -115,6 +115,7 @@ class ComposeActivityTest { accountManagerMock, mock(MediaUploader::class.java), mock(ServiceClient::class.java), + mock(DraftHelper::class.java), mock(SaveTootHelper::class.java), dbMock )