From d40b87f0a0c54e0f484342f5b541469371746297 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 12 Nov 2023 19:51:46 +0100 Subject: [PATCH] feat: Translate statuses on cached timelines (#220) Implement some support for server-side status translation. Do this by: - Implement support for the `api/v1/instance` endpoint to determine if the remote server supports translation. - Create new `ServerCapabilities` to allow the app to query the remote capabilities in a server-agnostic way. Use this to query if the remote server supports the Mastodon implementation of server-side translation - If translation is supported then show a translate/undo translate option on the status "..." menu. - Fetch translated content from the server if requested, and store it locally as a new Room entity. - Update displaying a status to check if the translated version should be displayed; if it should then new code is used to show translated content, content warning, poll options, and media descriptions. - Add a `TextView` to show an "in progress" message while translation is happening, and to show the translation provider (generally required by agreements with them). Partially fixes #62 --------- Co-authored-by: sanao --- app/build.gradle | 3 + app/lint-baseline.xml | 633 +++++---- app/schemas/app.pachli.db.AppDatabase/3.json | 1140 +++++++++++++++++ .../java/app/pachli/adapter/PollAdapter.kt | 108 +- .../pachli/adapter/StatusBaseViewHolder.kt | 43 +- .../instanceinfo/InstanceInfoRepository.kt | 2 +- .../components/login/LoginWebViewViewModel.kt | 2 +- .../notifications/NotificationsFragment.kt | 4 +- .../timeline/CachedTimelineRepository.kt | 121 +- .../components/timeline/TimelineFragment.kt | 37 +- .../timeline/viewmodel/TimelineViewModel.kt | 37 +- .../viewthread/ViewThreadFragment.kt | 15 +- .../viewthread/ViewThreadViewModel.kt | 48 + .../viewthread/edits/ViewEditsAdapter.kt | 31 +- .../main/java/app/pachli/db/AppDatabase.kt | 5 +- app/src/main/java/app/pachli/db/Converters.kt | 22 + .../main/java/app/pachli/db/TimelineDao.kt | 41 +- .../app/pachli/db/TimelineStatusEntity.kt | 7 + .../java/app/pachli/db/TranslatedStatusDao.kt | 27 + .../app/pachli/db/TranslatedStatusEntity.kt | 71 + .../main/java/app/pachli/di/DatabaseModule.kt | 3 + .../entity/{Instance.kt => InstanceV1.kt} | 9 +- .../java/app/pachli/entity/Translation.kt | 73 ++ .../java/app/pachli/fragment/SFragment.kt | 71 +- .../java/app/pachli/network/MastodonApi.kt | 14 +- .../java/app/pachli/network/Operations.kt | 181 +++ .../network/ServerCapabilitiesRepository.kt | 63 + .../app/pachli/network/model/InstanceV2.kt | 203 +++ .../java/app/pachli/usecase/TimelineCases.kt | 16 + .../app/pachli/util/StatusDisplayOptions.kt | 2 + .../util/StatusDisplayOptionsRepository.kt | 16 + app/src/main/java/app/pachli/view/PollView.kt | 70 +- .../java/app/pachli/viewdata/PollViewData.kt | 10 + .../app/pachli/viewdata/StatusViewData.kt | 52 +- app/src/main/res/layout/item_status.xml | 16 +- .../main/res/layout/item_status_detailed.xml | 16 +- app/src/main/res/menu/status_more.xml | 6 + app/src/main/res/values/strings.xml | 7 +- .../java/app/pachli/StatusComparisonTest.kt | 7 + .../components/compose/ComposeActivityTest.kt | 10 +- .../InstanceInfoRepositoryTest.kt | 10 +- .../NotificationsViewModelTestBase.kt | 15 + .../NotificationsViewModelTestStatusAction.kt | 2 + .../CachedTimelineViewModelTestBase.kt | 8 + ...CachedTimelineViewModelTestStatusAction.kt | 2 + .../NetworkTimelineViewModelTestBase.kt | 8 + ...etworkTimelineViewModelTestStatusAction.kt | 2 + .../components/timeline/StatusMocker.kt | 3 + .../viewthread/ViewThreadViewModelTest.kt | 8 + .../java/app/pachli/di/FakeDatabaseModule.kt | 3 + ...=> InstanceV1SwitchAuthInterceptorTest.kt} | 2 +- .../java/app/pachli/network/ServerKindTest.kt | 67 + .../app/pachli/usecase/TimelineCasesTest.kt | 5 +- .../StatusDisplayOptionsRepositoryTest.kt | 12 + gradle/libs.versions.toml | 4 + 55 files changed, 2843 insertions(+), 550 deletions(-) create mode 100644 app/schemas/app.pachli.db.AppDatabase/3.json create mode 100644 app/src/main/java/app/pachli/db/TranslatedStatusDao.kt create mode 100644 app/src/main/java/app/pachli/db/TranslatedStatusEntity.kt rename app/src/main/java/app/pachli/entity/{Instance.kt => InstanceV1.kt} (94%) create mode 100644 app/src/main/java/app/pachli/entity/Translation.kt create mode 100644 app/src/main/java/app/pachli/network/Operations.kt create mode 100644 app/src/main/java/app/pachli/network/ServerCapabilitiesRepository.kt create mode 100644 app/src/main/java/app/pachli/network/model/InstanceV2.kt rename app/src/test/java/app/pachli/network/{InstanceSwitchAuthInterceptorTest.kt => InstanceV1SwitchAuthInterceptorTest.kt} (98%) create mode 100644 app/src/test/java/app/pachli/network/ServerKindTest.kt diff --git a/app/build.gradle b/app/build.gradle index 2d0012646..03ef6fd17 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -224,6 +224,9 @@ dependencies { googleImplementation libs.app.update googleImplementation libs.app.update.ktx + implementation libs.kotlin.result + implementation libs.semver + testImplementation libs.androidx.test.junit testImplementation libs.robolectric testImplementation libs.bundles.mockito diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index fdd9ad7fc..5f8aa93e5 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -656,17 +656,6 @@ column="43"/> - - - - @@ -751,7 +740,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -762,7 +751,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -773,7 +762,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -953,39 +942,6 @@ column="9"/> - - - - - - - - - - - - @@ -1796,7 +1752,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1807,7 +1763,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1818,7 +1774,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1829,7 +1785,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1840,7 +1796,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1851,7 +1807,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1862,7 +1818,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -1873,7 +1829,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1884,7 +1840,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1895,7 +1851,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -1906,7 +1862,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> @@ -1917,7 +1873,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1928,7 +1884,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1939,7 +1895,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -1950,7 +1906,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1961,7 +1917,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -1972,7 +1928,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1983,7 +1939,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1994,7 +1950,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -2005,7 +1961,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2016,7 +1972,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2027,7 +1983,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2038,7 +1994,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2049,7 +2005,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2060,7 +2016,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2071,7 +2027,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2082,7 +2038,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3028,7 +2984,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3039,7 +2995,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -3050,7 +3006,7 @@ errorLine2=" ~~~~~~~"> @@ -3061,7 +3017,7 @@ errorLine2=" ~~~~~~~"> @@ -3072,7 +3028,7 @@ errorLine2=" ~~~~~~~"> @@ -3083,7 +3039,7 @@ errorLine2=" ~~~~~~~"> @@ -3094,7 +3050,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3105,7 +3061,7 @@ errorLine2=" ~~~~~~~"> @@ -3116,7 +3072,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3127,7 +3083,7 @@ errorLine2=" ~~~~~~~"> @@ -3136,185 +3092,9 @@ message="Access to `private` method `primaryDrawerItem` of class `MainActivityKt` requires synthetic accessor" errorLine1=" primaryDrawerItem {" errorLine2=" ~~~~~~~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -3325,54 +3105,10 @@ errorLine2=" ~~~~~~~"> - - - - - - - - - - - - - - - - @@ -3389,9 +3125,97 @@ message="Access to `private` method `setOnClick` of class `MainActivityKt` requires synthetic accessor" errorLine1=" onClick = {" errorLine2=" ~~~~~~~"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3413,7 +3237,139 @@ errorLine2=" ~~~~~~~"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3424,7 +3380,7 @@ errorLine2=" ~~~~~~~"> @@ -3435,7 +3391,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3446,7 +3402,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3688,7 +3644,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3699,7 +3655,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3710,7 +3666,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -3721,7 +3677,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3798,7 +3754,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3967,17 +3923,6 @@ column="37"/> - - - - @@ -4795,7 +4740,7 @@ errorLine2=" ~~~~~~~~"> @@ -4806,7 +4751,7 @@ errorLine2=" ~~~~~~~~"> @@ -4839,18 +4784,7 @@ errorLine2=" ~~~~~~~~"> - - - - @@ -4865,6 +4799,17 @@ column="6"/> + + + + diff --git a/app/schemas/app.pachli.db.AppDatabase/3.json b/app/schemas/app.pachli.db.AppDatabase/3.json new file mode 100644 index 000000000..fda5c9b27 --- /dev/null +++ b/app/schemas/app.pachli.db.AppDatabase/3.json @@ -0,0 +1,1140 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "bf1895b415cee0ff432966613d859668", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "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 + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT, `locked` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "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": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "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": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": true + }, + { + "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": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `timelineId` TEXT NOT NULL, `kind` TEXT NOT NULL, `key` TEXT, PRIMARY KEY(`accountId`, `timelineId`, `kind`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timelineId", + "columnName": "timelineId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId", + "timelineId", + "kind" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "StatusViewDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `translationState` TEXT NOT NULL DEFAULT 'SHOW_ORIGINAL', PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "translationState", + "columnName": "translationState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SHOW_ORIGINAL'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TranslatedStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `content` TEXT NOT NULL, `spoilerText` TEXT NOT NULL, `poll` TEXT, `attachments` TEXT NOT NULL, `provider` TEXT NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "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, 'bf1895b415cee0ff432966613d859668')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/app/pachli/adapter/PollAdapter.kt b/app/src/main/java/app/pachli/adapter/PollAdapter.kt index f8eb8ae51..5f91d5775 100644 --- a/app/src/main/java/app/pachli/adapter/PollAdapter.kt +++ b/app/src/main/java/app/pachli/adapter/PollAdapter.kt @@ -30,73 +30,71 @@ import app.pachli.viewdata.buildDescription import app.pachli.viewdata.calculatePercent import com.google.android.material.color.MaterialColors -class PollAdapter : RecyclerView.Adapter>() { - - private var pollOptions: List = emptyList() - private var voteCount: Int = 0 - private var votersCount: Int? = null - private var mode = RESULT - private var emojis: List = emptyList() - private var resultClickListener: View.OnClickListener? = null - private var animateEmojis = false - private var enabled = true - +// This can't take [app.pachli.viewdata.PollViewData] as a parameter as it also needs to show +// data from polls that have been edited, and the "shape" of that data is quite different (no +// information about vote counts, poll IDs, etc). +class PollAdapter( + val options: List, + private val votesCount: Int, + private val votersCount: Int?, + val emojis: List, + val animateEmojis: Boolean, + val displayMode: DisplayMode, + /** True if the user can vote in this poll, false otherwise (e.g., it's from an edit) */ + val enabled: Boolean = true, + /** Listener to call when the user clicks on the poll results */ + private val resultClickListener: View.OnClickListener? = null, /** Listener to call when the user clicks on a poll option */ - private var optionClickListener: View.OnClickListener? = null + private val pollOptionClickListener: View.OnClickListener? = null, +) : RecyclerView.Adapter>() { - @JvmOverloads - fun setup( - options: List, - voteCount: Int, - votersCount: Int?, - emojis: List, - mode: Int, - resultClickListener: View.OnClickListener?, - animateEmojis: Boolean, - enabled: Boolean = true, - optionClickListener: View.OnClickListener? = null, - ) { - this.pollOptions = options - this.voteCount = voteCount - this.votersCount = votersCount - this.emojis = emojis - this.mode = mode - this.resultClickListener = resultClickListener - this.animateEmojis = animateEmojis - this.enabled = enabled - this.optionClickListener = optionClickListener - notifyDataSetChanged() + /** How to display a poll */ + enum class DisplayMode { + /** Show the results, no voting */ + RESULT, + + /** Single choice (display as radio buttons) */ + SINGLE_CHOICE, + + /** Multiple choice (display as check boxes) */ + MULTIPLE_CHOICE, } - fun getSelected(): List { - return pollOptions.filter { it.selected } - .map { pollOptions.indexOf(it) } - } + /** @return the indices of the selected options */ + fun getSelected() = options.withIndex().filter { it.value.selected }.map { it.index } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { val binding = ItemPollBinding.inflate(LayoutInflater.from(parent.context), parent, false) return BindingHolder(binding) } - override fun getItemCount() = pollOptions.size + override fun getItemCount() = options.size override fun onBindViewHolder(holder: BindingHolder, position: Int) { - val option = pollOptions[position] + val option = options[position] val resultTextView = holder.binding.statusPollOptionResult val radioButton = holder.binding.statusPollRadioButton val checkBox = holder.binding.statusPollCheckbox - resultTextView.visible(mode == RESULT) - radioButton.visible(mode == SINGLE) - checkBox.visible(mode == MULTIPLE) + resultTextView.visible(displayMode == DisplayMode.RESULT) + radioButton.visible(displayMode == DisplayMode.SINGLE_CHOICE) + checkBox.visible(displayMode == DisplayMode.MULTIPLE_CHOICE) + // Enable/disable the option widgets as appropriate. Disabling them will also change + // the text colour, which is undesirable (this happens when showing status edits) so + // reset the text colour as necessary. + val defaultTextColor = radioButton.currentTextColor radioButton.isEnabled = enabled checkBox.isEnabled = enabled + if (!enabled) { + radioButton.setTextColor(defaultTextColor) + checkBox.setTextColor(defaultTextColor) + } - when (mode) { - RESULT -> { - val percent = calculatePercent(option.votesCount, votersCount, voteCount) + when (displayMode) { + DisplayMode.RESULT -> { + val percent = calculatePercent(option.votesCount, votersCount, votesCount) resultTextView.text = buildDescription(option.title, percent, option.voted, resultTextView.context) .emojify(emojis, resultTextView, animateEmojis) @@ -118,31 +116,25 @@ class PollAdapter : RecyclerView.Adapter>() { resultTextView.setTextColor(textColor) resultTextView.setOnClickListener(resultClickListener) } - SINGLE -> { + DisplayMode.SINGLE_CHOICE -> { radioButton.text = option.title.emojify(emojis, radioButton, animateEmojis) radioButton.isChecked = option.selected radioButton.setOnClickListener { - pollOptions.forEachIndexed { index, pollOption -> + options.forEachIndexed { index, pollOption -> pollOption.selected = index == holder.bindingAdapterPosition notifyItemChanged(index) } - optionClickListener?.onClick(radioButton) + pollOptionClickListener?.onClick(radioButton) } } - MULTIPLE -> { + DisplayMode.MULTIPLE_CHOICE -> { checkBox.text = option.title.emojify(emojis, checkBox, animateEmojis) checkBox.isChecked = option.selected checkBox.setOnCheckedChangeListener { _, isChecked -> - pollOptions[holder.bindingAdapterPosition].selected = isChecked - optionClickListener?.onClick(checkBox) + options[holder.bindingAdapterPosition].selected = isChecked + pollOptionClickListener?.onClick(checkBox) } } } } - - companion object { - const val RESULT = 0 - const val SINGLE = 1 - const val MULTIPLE = 2 - } } diff --git a/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt b/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt index e314d2031..282315538 100644 --- a/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt +++ b/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt @@ -40,19 +40,23 @@ import app.pachli.util.getFormattedDescription import app.pachli.util.getRelativeTimeSpanString import app.pachli.util.hide import app.pachli.util.loadAvatar +import app.pachli.util.makeIcon import app.pachli.util.setClickableMentions import app.pachli.util.setClickableText +import app.pachli.util.show import app.pachli.view.MediaPreviewImageView import app.pachli.view.MediaPreviewLayout import app.pachli.view.PollView import app.pachli.view.PreviewCardView import app.pachli.viewdata.PollViewData.Companion.from import app.pachli.viewdata.StatusViewData +import app.pachli.viewdata.TranslationState import at.connyduck.sparkbutton.SparkButton import at.connyduck.sparkbutton.helpers.Utils import com.bumptech.glide.Glide import com.google.android.material.button.MaterialButton import com.google.android.material.color.MaterialColors +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import java.text.NumberFormat import java.util.Date @@ -95,6 +99,7 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : private val avatarRadius36dp: Int private val avatarRadius24dp: Int private val mediaPreviewUnloaded: Drawable + private val translationProvider: TextView? init { context = itemView.context @@ -144,6 +149,10 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : moreButton, ), ) + translationProvider = itemView.findViewById(R.id.translationProvider)?.apply { + val icon = makeIcon(context, GoogleMaterial.Icon.gmd_translate, textSize.toInt()) + setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null) + } } protected fun setDisplayName( @@ -244,6 +253,24 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : listener: StatusActionListener, ) { val (_, _, _, _, _, _, _, _, _, emojis, _, _, _, _, _, _, _, _, _, _, mentions, tags, _, _, _, poll) = status.actionable + when (status.translationState) { + TranslationState.SHOW_ORIGINAL -> translationProvider?.hide() + TranslationState.TRANSLATING -> { + translationProvider?.apply { + text = context.getString(R.string.translating) + show() + } + } + TranslationState.SHOW_TRANSLATION -> { + translationProvider?.apply { + status.translation?.provider?.let { + text = context.getString(R.string.translation_provider_fmt, it) + show() + } + } + } + } + val content = status.content if (expanded) { val emojifiedText = @@ -254,8 +281,14 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : } poll?.let { + val pollViewData = if (status.translationState == TranslationState.SHOW_TRANSLATION) { + from(it).copy(translatedPoll = status.translation?.poll) + } else { + from(it) + } + pollView.bind( - from(it), + pollViewData, emojis, statusDisplayOptions, numberFormat, @@ -705,7 +738,13 @@ abstract class StatusBaseViewHolder protected constructor(itemView: View) : setReblogged(actionable.reblogged) setFavourited(actionable.favourited) setBookmarked(actionable.bookmarked) - val attachments = actionable.attachments + val attachments = if (status.translationState == TranslationState.SHOW_TRANSLATION) { + status.translation?.attachments?.zip(actionable.attachments) { t, a -> + a.copy(description = t.description) + } ?: actionable.attachments + } else { + actionable.attachments + } val sensitive = actionable.sensitive if (statusDisplayOptions.mediaPreviewEnabled && hasPreviewableAttachment(attachments)) { setMediaPreviews( diff --git a/app/src/main/java/app/pachli/components/instanceinfo/InstanceInfoRepository.kt b/app/src/main/java/app/pachli/components/instanceinfo/InstanceInfoRepository.kt index ea72b19d9..d3f5030f5 100644 --- a/app/src/main/java/app/pachli/components/instanceinfo/InstanceInfoRepository.kt +++ b/app/src/main/java/app/pachli/components/instanceinfo/InstanceInfoRepository.kt @@ -57,7 +57,7 @@ class InstanceInfoRepository @Inject constructor( * Never throws, returns defaults of vanilla Mastodon in case of error. */ suspend fun getInstanceInfo(): InstanceInfo = withContext(Dispatchers.IO) { - api.getInstance() + api.getInstanceV1() .fold( { instance -> val instanceEntity = InstanceInfoEntity( diff --git a/app/src/main/java/app/pachli/components/login/LoginWebViewViewModel.kt b/app/src/main/java/app/pachli/components/login/LoginWebViewViewModel.kt index 4c0efeb79..41b51f832 100644 --- a/app/src/main/java/app/pachli/components/login/LoginWebViewViewModel.kt +++ b/app/src/main/java/app/pachli/components/login/LoginWebViewViewModel.kt @@ -39,7 +39,7 @@ class LoginWebViewViewModel @Inject constructor( if (this.domain == null) { this.domain = domain viewModelScope.launch { - api.getInstance(domain).fold({ instance -> + api.getInstanceV1(domain).fold({ instance -> instanceRules.value = instance.rules?.map { rule -> rule.text }.orEmpty() }, { throwable -> Timber.w("failed to load instance info", throwable) diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt b/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt index 35839ebab..802c1db49 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt @@ -538,8 +538,8 @@ class NotificationsFragment : } override fun onMore(view: View, position: Int) { - val status = adapter.peek(position)?.statusViewData?.status ?: return - super.more(status, view, position) + val statusViewData = adapter.peek(position)?.statusViewData ?: return + super.more(statusViewData, view, position) } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { diff --git a/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt b/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt index 26cd2fdc6..296338e24 100644 --- a/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt +++ b/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt @@ -28,17 +28,26 @@ import app.pachli.db.RemoteKeyDao import app.pachli.db.StatusViewDataEntity import app.pachli.db.TimelineDao import app.pachli.db.TimelineStatusWithAccount +import app.pachli.db.TranslatedStatusDao +import app.pachli.db.TranslatedStatusEntity import app.pachli.di.ApplicationScope import app.pachli.di.TransactionProvider +import app.pachli.entity.Translation import app.pachli.network.MastodonApi import app.pachli.util.EmptyPagingSource import app.pachli.viewdata.StatusViewData +import app.pachli.viewdata.TranslationState +import at.connyduck.calladapter.networkresult.NetworkResult +import at.connyduck.calladapter.networkresult.fold import com.google.gson.Gson import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject +import javax.inject.Singleton // TODO: This is very similar to NetworkTimelineRepository. They could be merged (and the use // of the cache be made a parameter to getStatusStream), except that they return Pagers of @@ -48,21 +57,23 @@ import javax.inject.Inject // // Re-writing the caching so that they can use the same types is the TODO. +@Singleton class CachedTimelineRepository @Inject constructor( private val mastodonApi: MastodonApi, private val accountManager: AccountManager, private val transactionProvider: TransactionProvider, val timelineDao: TimelineDao, private val remoteKeyDao: RemoteKeyDao, + private val translatedStatusDao: TranslatedStatusDao, private val gson: Gson, @ApplicationScope private val externalScope: CoroutineScope, ) { private var factory: InvalidatingPagingSourceFactory? = null - private val activeAccount = accountManager.activeAccount + private var activeAccount = accountManager.activeAccount /** @return flow of Mastodon [TimelineStatusWithAccount], loaded in [pageSize] increments */ - @OptIn(ExperimentalPagingApi::class) + @OptIn(ExperimentalPagingApi::class, ExperimentalCoroutinesApi::class) fun getStatusStream( kind: TimelineKind, pageSize: Int = PAGE_SIZE, @@ -70,39 +81,47 @@ class CachedTimelineRepository @Inject constructor( ): Flow> { Timber.d("getStatusStream(): key: $initialKey") - factory = InvalidatingPagingSourceFactory { - activeAccount?.let { timelineDao.getStatuses(it.id) } ?: EmptyPagingSource() - } + return accountManager.activeAccountFlow.flatMapLatest { + activeAccount = it - val row = initialKey?.let { key -> - // Room is row-keyed (by Int), not item-keyed, so the status ID string that was - // passed as `initialKey` won't work. - // - // Instead, get all the status IDs for this account, in timeline order, and find the - // row index that contains the status. The row index is the correct initialKey. - activeAccount?.let { account -> - timelineDao.getStatusRowNumber(account.id) - .indexOfFirst { it == key }.takeIf { it != -1 } + factory = InvalidatingPagingSourceFactory { + activeAccount?.let { timelineDao.getStatuses(it.id) } ?: EmptyPagingSource() } + + val row = initialKey?.let { key -> + // Room is row-keyed (by Int), not item-keyed, so the status ID string that was + // passed as `initialKey` won't work. + // + // Instead, get all the status IDs for this account, in timeline order, and find the + // row index that contains the status. The row index is the correct initialKey. + activeAccount?.let { account -> + timelineDao.getStatusRowNumber(account.id) + .indexOfFirst { it == key }.takeIf { it != -1 } + } + } + + Timber.d("initialKey: $initialKey is row: $row") + + Pager( + config = PagingConfig( + pageSize = pageSize, + jumpThreshold = PAGE_SIZE * 3, + enablePlaceholders = true, + ), + initialKey = row, + remoteMediator = CachedTimelineRemoteMediator( + initialKey, + mastodonApi, + accountManager, + factory!!, + transactionProvider, + timelineDao, + remoteKeyDao, + gson, + ), + pagingSourceFactory = factory!!, + ).flow } - - Timber.d("initialKey: $initialKey is row: $row") - - return Pager( - config = PagingConfig(pageSize = pageSize, jumpThreshold = PAGE_SIZE * 3, enablePlaceholders = true), - initialKey = row, - remoteMediator = CachedTimelineRemoteMediator( - initialKey, - mastodonApi, - accountManager, - factory!!, - transactionProvider, - timelineDao, - remoteKeyDao, - gson, - ), - pagingSourceFactory = factory!!, - ).flow } /** Invalidate the active paging source, see [androidx.paging.PagingSource.invalidate] */ @@ -124,6 +143,7 @@ class CachedTimelineRepository @Inject constructor( expanded = statusViewData.isExpanded, contentShowing = statusViewData.isShowingContent, contentCollapsed = statusViewData.isCollapsed, + translationState = statusViewData.translationState, ), ) }.join() @@ -157,9 +177,40 @@ class CachedTimelineRepository @Inject constructor( }.join() suspend fun clearAndReloadFromNewest() = externalScope.launch { - timelineDao.removeAll(activeAccount!!.id) - remoteKeyDao.delete(activeAccount.id) - invalidate() + activeAccount?.let { + timelineDao.removeAll(it.id) + remoteKeyDao.delete(it.id) + invalidate() + } + } + + suspend fun translate(statusViewData: StatusViewData): NetworkResult { + saveStatusViewData(statusViewData.copy(translationState = TranslationState.TRANSLATING)) + val translation = mastodonApi.translate(statusViewData.actionableId) + translation.fold({ + translatedStatusDao.upsert( + TranslatedStatusEntity( + serverId = statusViewData.actionableId, + timelineUserId = activeAccount!!.id, + // TODO: Should this embed the network type instead of copying data + // from one type to another? + content = it.content, + spoilerText = it.spoilerText, + poll = it.poll, + attachments = it.attachments, + provider = it.provider, + ), + ) + saveStatusViewData(statusViewData.copy(translationState = TranslationState.SHOW_TRANSLATION)) + }, { + // Reset the translation state + saveStatusViewData(statusViewData) + },) + return translation + } + + suspend fun translateUndo(statusViewData: StatusViewData) { + saveStatusViewData(statusViewData.copy(translationState = TranslationState.SHOW_ORIGINAL)) } companion object { diff --git a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt index 7ca6b5be3..50109dbbf 100644 --- a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt @@ -72,6 +72,7 @@ import app.pachli.util.visible import app.pachli.util.withPresentationState import app.pachli.viewdata.AttachmentViewData import app.pachli.viewdata.StatusViewData +import app.pachli.viewdata.TranslationState import at.connyduck.sparkbutton.helpers.Utils import com.google.android.material.color.MaterialColors import com.google.android.material.divider.MaterialDividerItemDecoration @@ -289,6 +290,7 @@ class TimelineFragment : statusViewData.status.copy( poll = it.action.poll.votedCopy(it.action.choices), ) + is StatusActionSuccess.Translate -> statusViewData.status } (indexedViewData.value as StatusViewData).status = status @@ -610,8 +612,8 @@ class TimelineFragment : } override fun onMore(view: View, position: Int) { - val status = adapter.peek(position) ?: return - super.more(status.status, view, position) + val statusViewData = adapter.peek(position) ?: return + super.more(statusViewData, view, position) } override fun onOpenReblog(position: Int) { @@ -646,13 +648,32 @@ class TimelineFragment : viewModel.changeContentCollapsed(isCollapsed, status) } + // Can only translate the home timeline at the moment + override fun canTranslate() = timelineKind == TimelineKind.Home + + override fun onTranslate(statusViewData: StatusViewData) { + viewModel.accept(StatusAction.Translate(statusViewData)) + } + + override fun onTranslateUndo(statusViewData: StatusViewData) { + viewModel.accept(InfallibleUiAction.TranslateUndo(statusViewData)) + } + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - val status = adapter.peek(position) ?: return - super.viewMedia( - attachmentIndex, - AttachmentViewData.list(status.actionable), - view, - ) + val statusViewData = adapter.peek(position) ?: return + + // Pass the translated media descriptions through (if appropriate) + val actionable = if (statusViewData.translationState == TranslationState.SHOW_TRANSLATION) { + statusViewData.actionable.copy( + attachments = statusViewData.translation?.attachments?.zip(statusViewData.actionable.attachments) { t, a -> + a.copy(description = t.description) + } ?: statusViewData.actionable.attachments, + ) + } else { + statusViewData.actionable + } + + super.viewMedia(attachmentIndex, AttachmentViewData.list(actionable), view) } override fun onViewThread(position: Int) { diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt index afa8f295e..30fbfcf95 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt @@ -82,18 +82,6 @@ data class UiState( val showFabWhileScrolling: Boolean, ) -/** Preferences the UI reacts to */ -data class UiPrefs( - val showFabWhileScrolling: Boolean, -) { - companion object { - /** Relevant preference keys. Changes to any of these trigger a display update */ - val prefKeys = setOf( - PrefKeys.FAB_HIDE, - ) - } -} - // TODO: Ui* classes are copied from NotificationsViewModel. Not yet sure whether these actions // are "global" across all timelines (including notifications) or whether notifications are // sufficiently different to warrant having a duplicate set. Keeping them duplicated for the @@ -125,6 +113,8 @@ sealed interface InfallibleUiAction : UiAction { // infallible. Reloading the data may fail, but that's handled by the paging system / // adapter refresh logic. data object LoadNewest : InfallibleUiAction + + data class TranslateUndo(val statusViewData: StatusViewData) : InfallibleUiAction } sealed interface UiSuccess { @@ -171,6 +161,9 @@ sealed interface StatusAction : FallibleUiAction { val choices: List, override val statusViewData: StatusViewData, ) : StatusAction + + /** Translate a status */ + data class Translate(override val statusViewData: StatusViewData) : StatusAction } /** Changes to a status' visible state after API calls */ @@ -185,12 +178,15 @@ sealed interface StatusActionSuccess : UiSuccess { data class VoteInPoll(override val action: StatusAction.VoteInPoll) : StatusActionSuccess + data class Translate(override val action: StatusAction.Translate) : StatusActionSuccess + companion object { fun from(action: StatusAction) = when (action) { is StatusAction.Bookmark -> Bookmark(action) is StatusAction.Favourite -> Favourite(action) is StatusAction.Reblog -> Reblog(action) is StatusAction.VoteInPoll -> VoteInPoll(action) + is StatusAction.Translate -> Translate(action) } } } @@ -239,6 +235,12 @@ sealed interface UiError { override val message: Int = R.string.ui_error_vote, ) : UiError + data class TranslateStatus( + override val throwable: Throwable, + override val action: StatusAction.Translate, + override val message: Int = R.string.ui_error_translate_status, + ) : UiError + data class GetFilters( override val throwable: Throwable, override val action: UiAction? = null, @@ -251,6 +253,7 @@ sealed interface UiError { is StatusAction.Favourite -> Favourite(throwable, action) is StatusAction.Reblog -> Reblog(throwable, action) is StatusAction.VoteInPoll -> VoteInPoll(throwable, action) + is StatusAction.Translate -> TranslateStatus(throwable, action) } } } @@ -350,6 +353,9 @@ abstract class TimelineViewModel( action.poll.id, action.choices, ) + is StatusAction.Translate -> { + timelineCases.translate(action.statusViewData) + } }.getOrThrow() uiSuccess.emit(StatusActionSuccess.from(action)) } catch (e: Exception) { @@ -427,6 +433,13 @@ abstract class TimelineViewModel( } } + // Undo status translations + viewModelScope.launch { + uiAction.filterIsInstance().collectLatest { + timelineCases.translateUndo(it.statusViewData) + } + } + viewModelScope.launch { eventHub.events .collect { event -> handleEvent(event) } diff --git a/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt index 262ccf3ce..326e0221b 100644 --- a/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt @@ -215,7 +215,8 @@ class ViewThreadFragment : lifecycleScope.launch { viewModel.errors.collect { throwable -> Timber.w("failed to load status context", throwable) - Snackbar.make(binding.root, R.string.error_generic, Snackbar.LENGTH_SHORT) + val msg = view.context.getString(R.string.error_generic_fmt, throwable) + Snackbar.make(binding.root, msg, Snackbar.LENGTH_INDEFINITE) .setAction(R.string.action_retry) { viewModel.retry(thisThreadsStatusId) } @@ -256,6 +257,16 @@ class ViewThreadFragment : } } + override fun canTranslate() = true + + override fun onTranslate(statusViewData: StatusViewData) { + viewModel.translate(statusViewData) + } + + override fun onTranslateUndo(statusViewData: StatusViewData) { + viewModel.translateUndo(statusViewData) + } + override fun onResume() { super.onResume() requireActivity().title = getString(R.string.title_view_thread) @@ -307,7 +318,7 @@ class ViewThreadFragment : } override fun onMore(view: View, position: Int) { - super.more(adapter.currentList[position].status, view, position) + super.more(adapter.currentList[position], view, position) } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { diff --git a/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt index 432864947..fb8fbd718 100644 --- a/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt @@ -35,6 +35,7 @@ import app.pachli.components.timeline.util.ifExpected import app.pachli.db.AccountEntity import app.pachli.db.AccountManager import app.pachli.db.TimelineDao +import app.pachli.db.TranslatedStatusEntity import app.pachli.entity.Filter import app.pachli.entity.Status import app.pachli.network.FilterModel @@ -42,6 +43,7 @@ import app.pachli.network.MastodonApi import app.pachli.usecase.TimelineCases import app.pachli.util.StatusDisplayOptionsRepository import app.pachli.viewdata.StatusViewData +import app.pachli.viewdata.TranslationState import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrThrow @@ -55,6 +57,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import retrofit2.HttpException import timber.log.Timber import javax.inject.Inject @@ -142,6 +145,8 @@ class ViewThreadViewModel @Inject constructor( isShowingContent = timelineStatusWithAccount.viewData?.contentShowing ?: (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), isCollapsed = timelineStatusWithAccount.viewData?.contentCollapsed ?: true, isDetailed = true, + translationState = timelineStatusWithAccount.viewData?.translationState ?: TranslationState.SHOW_ORIGINAL, + translation = timelineStatusWithAccount.translatedStatus, ) } else { StatusViewData.from( @@ -150,6 +155,7 @@ class ViewThreadViewModel @Inject constructor( isExpanded = alwaysOpenSpoiler, isShowingContent = (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), isDetailed = true, + translationState = TranslationState.SHOW_ORIGINAL, ) } } else { @@ -178,6 +184,8 @@ class ViewThreadViewModel @Inject constructor( isExpanded = detailedStatus.isExpanded, isCollapsed = detailedStatus.isCollapsed, isDetailed = true, + translationState = detailedStatus.translationState, + translation = detailedStatus.translation, ) } } @@ -196,6 +204,7 @@ class ViewThreadViewModel @Inject constructor( isExpanded = svd?.expanded ?: alwaysOpenSpoiler, isCollapsed = svd?.contentCollapsed ?: true, isDetailed = false, + translationState = svd?.translationState ?: TranslationState.SHOW_ORIGINAL, ) }.filterByFilterAction() val descendants = statusContext.descendants.map { @@ -207,6 +216,7 @@ class ViewThreadViewModel @Inject constructor( isExpanded = svd?.expanded ?: alwaysOpenSpoiler, isCollapsed = svd?.contentCollapsed ?: true, isDetailed = false, + translationState = svd?.translationState ?: TranslationState.SHOW_ORIGINAL, ) }.filterByFilterAction() val statuses = ancestors + detailedStatus + descendants @@ -438,6 +448,44 @@ class ViewThreadViewModel @Inject constructor( } } + fun translate(statusViewData: StatusViewData) { + viewModelScope.launch { + repository.translate(statusViewData).fold({ + val translatedEntity = TranslatedStatusEntity( + serverId = statusViewData.actionableId, + timelineUserId = activeAccount.id, + content = it.content, + spoilerText = it.spoilerText, + poll = it.poll, + attachments = it.attachments, + provider = it.provider, + ) + updateStatusViewData(statusViewData.status.id) { viewData -> + viewData.copy(translation = translatedEntity, translationState = TranslationState.SHOW_TRANSLATION) + } + }, { + // Mastodon returns 403 if it thinks the original status language is the + // same as the user's language, ignoring the actual content of the status + // (https://github.com/mastodon/documentation/issues/1330). Nothing useful + // to do here so swallow the error + if (it is HttpException && it.code() == 403) return@fold + + _errors.emit(it) + },) + } + } + + fun translateUndo(statusViewData: StatusViewData) { + updateStatusViewData(statusViewData.status.id) { viewData -> + viewData.copy(translationState = TranslationState.SHOW_ORIGINAL) + } + viewModelScope.launch { + repository.saveStatusViewData( + statusViewData.copy(translationState = TranslationState.SHOW_ORIGINAL), + ) + } + } + private fun StatusViewData.getRevealButtonState(): RevealButtonState { val hasWarnings = status.spoilerText.isNotEmpty() diff --git a/app/src/main/java/app/pachli/components/viewthread/edits/ViewEditsAdapter.kt b/app/src/main/java/app/pachli/components/viewthread/edits/ViewEditsAdapter.kt index 0bc6d7156..3838ea132 100644 --- a/app/src/main/java/app/pachli/components/viewthread/edits/ViewEditsAdapter.kt +++ b/app/src/main/java/app/pachli/components/viewthread/edits/ViewEditsAdapter.kt @@ -19,8 +19,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import app.pachli.R import app.pachli.adapter.PollAdapter -import app.pachli.adapter.PollAdapter.Companion.MULTIPLE -import app.pachli.adapter.PollAdapter.Companion.SINGLE +import app.pachli.adapter.PollAdapter.DisplayMode import app.pachli.databinding.ItemStatusEditBinding import app.pachli.entity.Attachment.Focus import app.pachli.entity.StatusEdit @@ -133,24 +132,20 @@ class ViewEditsAdapter( // https://github.com/mastodon/mastodon/issues/22571 // binding.statusEditPollDescription.show() - val pollAdapter = PollAdapter() + val pollAdapter = PollAdapter( + options = edit.poll.options.map { PollOptionViewData.from(it, false) }, + votesCount = 0, + votersCount = null, + edit.emojis, + animateEmojis = animateEmojis, + displayMode = if (edit.poll.multiple) DisplayMode.MULTIPLE_CHOICE else DisplayMode.SINGLE_CHOICE, + enabled = false, + resultClickListener = null, + pollOptionClickListener = null, + ) + binding.statusEditPollOptions.adapter = pollAdapter binding.statusEditPollOptions.layoutManager = LinearLayoutManager(context) - - pollAdapter.setup( - options = edit.poll.options.map { PollOptionViewData.from(it, false) }, - voteCount = 0, - votersCount = null, - emojis = edit.emojis, - mode = if (edit.poll.multiple) { // not reported by the api - MULTIPLE - } else { - SINGLE - }, - resultClickListener = null, - animateEmojis = animateEmojis, - enabled = false, - ) } if (edit.mediaAttachments.isEmpty()) { diff --git a/app/src/main/java/app/pachli/db/AppDatabase.kt b/app/src/main/java/app/pachli/db/AppDatabase.kt index 320433c30..ff6b55f2f 100644 --- a/app/src/main/java/app/pachli/db/AppDatabase.kt +++ b/app/src/main/java/app/pachli/db/AppDatabase.kt @@ -35,10 +35,12 @@ import app.pachli.components.conversation.ConversationEntity ConversationEntity::class, RemoteKeyEntity::class, StatusViewDataEntity::class, + TranslatedStatusEntity::class, ], - version = 2, + version = 3, autoMigrations = [ AutoMigration(from = 1, to = 2, spec = AppDatabase.MIGRATE_1_2::class), + AutoMigration(from = 2, to = 3), ], ) abstract class AppDatabase : RoomDatabase() { @@ -48,6 +50,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun timelineDao(): TimelineDao abstract fun draftDao(): DraftDao abstract fun remoteKeyDao(): RemoteKeyDao + abstract fun translatedStatusDao(): TranslatedStatusDao @DeleteColumn("TimelineStatusEntity", "expanded") @DeleteColumn("TimelineStatusEntity", "contentCollapsed") diff --git a/app/src/main/java/app/pachli/db/Converters.kt b/app/src/main/java/app/pachli/db/Converters.kt index c7144f91c..104791de8 100644 --- a/app/src/main/java/app/pachli/db/Converters.kt +++ b/app/src/main/java/app/pachli/db/Converters.kt @@ -28,6 +28,8 @@ import app.pachli.entity.HashTag import app.pachli.entity.NewPoll import app.pachli.entity.Poll import app.pachli.entity.Status +import app.pachli.entity.TranslatedAttachment +import app.pachli.entity.TranslatedPoll import com.google.gson.Gson import com.google.gson.reflect.TypeToken import java.net.URLDecoder @@ -176,4 +178,24 @@ class Converters @Inject constructor( fun jsonToFilterResultList(filterResultListJson: String?): List? { return gson.fromJson(filterResultListJson, object : TypeToken>() {}.type) } + + @TypeConverter + fun translatedPolltoJson(translatedPoll: TranslatedPoll?): String? { + return gson.toJson(translatedPoll) + } + + @TypeConverter + fun jsonToTranslatedPoll(translatedPollJson: String?): TranslatedPoll? { + return gson.fromJson(translatedPollJson, TranslatedPoll::class.java) + } + + @TypeConverter + fun translatedAttachmentToJson(translatedAttachment: List?): String { + return gson.toJson(translatedAttachment) + } + + @TypeConverter + fun jsonToTranslatedAttachment(translatedAttachmentJson: String): List? { + return gson.fromJson(translatedAttachmentJson, object : TypeToken>() {}.type) + } } diff --git a/app/src/main/java/app/pachli/db/TimelineDao.kt b/app/src/main/java/app/pachli/db/TimelineDao.kt index 9b9aa5f76..3bde32be3 100644 --- a/app/src/main/java/app/pachli/db/TimelineDao.kt +++ b/app/src/main/java/app/pachli/db/TimelineDao.kt @@ -50,11 +50,15 @@ rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar' rb.emojis as 'rb_emojis', rb.bot as 'rb_bot', svd.serverId as 'svd_serverId', svd.timelineUserId as 'svd_timelineUserId', svd.expanded as 'svd_expanded', svd.contentShowing as 'svd_contentShowing', -svd.contentCollapsed as 'svd_contentCollapsed' +svd.contentCollapsed as 'svd_contentCollapsed', svd.translationState as 'svd_translationState', +t.serverId as 't_serverId', t.timelineUserId as 't_timelineUserId', t.content as 't_content', +t.spoilerText as 't_spoilerText', t.poll as 't_poll', t.attachments as 't_attachments', +t.provider as 't_provider' FROM TimelineStatusEntity s LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) LEFT JOIN StatusViewDataEntity svd ON (s.timelineUserId = svd.timelineUserId AND (s.serverId = svd.serverId OR s.reblogServerId = svd.serverId)) +LEFT JOIN TranslatedStatusEntity t ON (s.timelineUserId = t.timelineUserId AND (s.serverId = t.serverId OR s.reblogServerId = t.serverId)) WHERE s.timelineUserId = :account ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC""", ) @@ -92,11 +96,15 @@ rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar' rb.emojis as 'rb_emojis', rb.bot as 'rb_bot', svd.serverId as 'svd_serverId', svd.timelineUserId as 'svd_timelineUserId', svd.expanded as 'svd_expanded', svd.contentShowing as 'svd_contentShowing', -svd.contentCollapsed as 'svd_contentCollapsed' +svd.contentCollapsed as 'svd_contentCollapsed', svd.translationState as 'svd_translationState', +t.serverId as 't_serverId', t.timelineUserId as 't_timelineUserId', t.content as 't_content', +t.spoilerText as 't_spoilerText', t.poll as 't_poll', t.attachments as 't_attachments', +t.provider as 't_provider' FROM TimelineStatusEntity s LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) LEFT JOIN StatusViewDataEntity svd ON (s.timelineUserId = svd.timelineUserId AND (s.serverId = svd.serverId OR s.reblogServerId = svd.serverId)) +LEFT JOIN TranslatedStatusEntity t ON (s.timelineUserId = t.timelineUserId AND (s.serverId = t.serverId OR s.reblogServerId = t.serverId)) WHERE (s.serverId = :statusId OR s.reblogServerId = :statusId) AND s.authorServerId IS NOT NULL""", ) @@ -136,12 +144,20 @@ WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = abstract suspend fun removeAllByUser(accountId: Long, userId: String) /** - * Removes everything in the TimelineStatusEntity and TimelineAccountEntity tables for one user account + * Removes everything for one account in the following tables: + * + * - TimelineStatusEntity + * - TimelineAccountEntity + * - StatusViewDataEntity + * - TranslatedStatusEntity + * * @param accountId id of the account for which to clean tables */ suspend fun removeAll(accountId: Long) { removeAllStatuses(accountId) removeAllAccounts(accountId) + removeAllStatusViewData(accountId) + removeAllTranslatedStatus(accountId) } @Query("DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId") @@ -153,6 +169,9 @@ WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = @Query("DELETE FROM StatusViewDataEntity WHERE timelineUserId = :accountId") abstract suspend fun removeAllStatusViewData(accountId: Long) + @Query("DELETE FROM TranslatedStatusEntity WHERE timelineUserId = :accountId") + abstract suspend fun removeAllTranslatedStatus(accountId: Long) + @Query( """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND serverId = :statusId""", @@ -168,6 +187,7 @@ AND serverId = :statusId""", cleanupStatuses(accountId, limit) cleanupAccounts(accountId) cleanupStatusViewData(accountId, limit) + cleanupTranslatedStatus(accountId, limit) } /** @@ -209,6 +229,21 @@ AND serverId = :statusId""", ) abstract suspend fun cleanupStatusViewData(accountId: Long, limit: Int) + /** + * Cleans the TranslatedStatusEntity table of old data, keeping the most recent [limit] + * entries. + */ + @Query( + """DELETE + FROM TranslatedStatusEntity + WHERE timelineUserId = :accountId + AND serverId NOT IN ( + SELECT serverId FROM TranslatedStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :limit + ) + """, + ) + abstract suspend fun cleanupTranslatedStatus(accountId: Long, limit: Int) + @Query( """UPDATE TimelineStatusEntity SET poll = :poll WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""", diff --git a/app/src/main/java/app/pachli/db/TimelineStatusEntity.kt b/app/src/main/java/app/pachli/db/TimelineStatusEntity.kt index cf3167f9f..93238fdce 100644 --- a/app/src/main/java/app/pachli/db/TimelineStatusEntity.kt +++ b/app/src/main/java/app/pachli/db/TimelineStatusEntity.kt @@ -16,6 +16,7 @@ package app.pachli.db +import androidx.room.ColumnInfo import androidx.room.Embedded import androidx.room.Entity import androidx.room.ForeignKey @@ -29,6 +30,7 @@ import app.pachli.entity.HashTag import app.pachli.entity.Poll import app.pachli.entity.Status import app.pachli.entity.TimelineAccount +import app.pachli.viewdata.TranslationState import com.google.gson.Gson import com.google.gson.reflect.TypeToken import java.lang.reflect.Type @@ -193,6 +195,9 @@ data class StatusViewDataEntity( val contentShowing: Boolean, /** Corresponds to [app.pachli.viewdata.StatusViewData.isCollapsed] */ val contentCollapsed: Boolean, + /** Show the translated version of the status (if it exists) */ + @ColumnInfo(defaultValue = "SHOW_ORIGINAL") + val translationState: TranslationState, ) val attachmentArrayListType: Type = object : TypeToken>() {}.type @@ -209,6 +214,8 @@ data class TimelineStatusWithAccount( val reblogAccount: TimelineAccountEntity? = null, // null when no reblog @Embedded(prefix = "svd_") val viewData: StatusViewDataEntity? = null, + @Embedded(prefix = "t_") + val translatedStatus: TranslatedStatusEntity? = null, ) { fun toStatus(gson: Gson): Status { val attachments: ArrayList = gson.fromJson( diff --git a/app/src/main/java/app/pachli/db/TranslatedStatusDao.kt b/app/src/main/java/app/pachli/db/TranslatedStatusDao.kt new file mode 100644 index 000000000..2fdf1b61e --- /dev/null +++ b/app/src/main/java/app/pachli/db/TranslatedStatusDao.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2023 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.db + +import androidx.room.Dao +import androidx.room.Upsert + +@Dao +interface TranslatedStatusDao { + @Upsert + suspend fun upsert(translatedStatusEntity: TranslatedStatusEntity) +} diff --git a/app/src/main/java/app/pachli/db/TranslatedStatusEntity.kt b/app/src/main/java/app/pachli/db/TranslatedStatusEntity.kt new file mode 100644 index 000000000..a4ccd9ec9 --- /dev/null +++ b/app/src/main/java/app/pachli/db/TranslatedStatusEntity.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2023 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.db + +import androidx.room.Entity +import androidx.room.TypeConverters +import app.pachli.entity.Status +import app.pachli.entity.TranslatedAttachment +import app.pachli.entity.TranslatedPoll + +/** + * Translated version of a status, see https://docs.joinmastodon.org/entities/Translation/. + * + * There is *no* foreignkey relationship between this and [TimelineStatusEntity], as the + * translation data is kept even if the status is deleted from the local cache (e.g., during + * a refresh operation). + */ +@Entity( + primaryKeys = ["serverId", "timelineUserId"], +) +@TypeConverters(Converters::class) +data class TranslatedStatusEntity( + /** ID of the status as it appeared on the original server */ + val serverId: String, + + /** Pachli ID for the logged in user, in case there are multiple accounts per instance */ + val timelineUserId: Long, + + /** The translated text of the status (HTML), equivalent to [Status.content] */ + val content: String, + + /** + * The translated spoiler text of the status (text), if it exists, equivalent to + * [Status.spoilerText] + */ + // Not documented, see https://github.com/mastodon/documentation/issues/1248 + val spoilerText: String, + + /** + * The translated poll (if it exists). Does not contain all the poll data, only the + * translated text. Vote counts and other metadata has to be determined from the original + * poll object. + */ + // Not documented, see https://github.com/mastodon/documentation/issues/1248 + val poll: TranslatedPoll?, + + /** + * Translated descriptions for media attachments, if any were attached. Other metadata has + * to be determined from the original attachment. + */ + // Not documented, see https://github.com/mastodon/documentation/issues/1248 + val attachments: List, + + /** The service that provided the machine translation */ + val provider: String, +) diff --git a/app/src/main/java/app/pachli/di/DatabaseModule.kt b/app/src/main/java/app/pachli/di/DatabaseModule.kt index 31109edf5..f6e37731d 100644 --- a/app/src/main/java/app/pachli/di/DatabaseModule.kt +++ b/app/src/main/java/app/pachli/di/DatabaseModule.kt @@ -65,6 +65,9 @@ object DatabaseModule { @Provides fun provideRemoteKeyDao(appDatabase: AppDatabase) = appDatabase.remoteKeyDao() + + @Provides + fun providesTranslatedStatusDao(appDatabase: AppDatabase) = appDatabase.translatedStatusDao() } /** diff --git a/app/src/main/java/app/pachli/entity/Instance.kt b/app/src/main/java/app/pachli/entity/InstanceV1.kt similarity index 94% rename from app/src/main/java/app/pachli/entity/Instance.kt rename to app/src/main/java/app/pachli/entity/InstanceV1.kt index 75474af50..414b2d281 100644 --- a/app/src/main/java/app/pachli/entity/Instance.kt +++ b/app/src/main/java/app/pachli/entity/InstanceV1.kt @@ -18,7 +18,8 @@ package app.pachli.entity import com.google.gson.annotations.SerializedName -data class Instance( +/** https://docs.joinmastodon.org/entities/V1_Instance/ */ +data class InstanceV1( val uri: String, // val title: String, // val description: String, @@ -42,11 +43,11 @@ data class Instance( } override fun equals(other: Any?): Boolean { - if (other !is Instance) { + if (other !is InstanceV1) { return false } - val instance = other as Instance? - return instance?.uri.equals(uri) + val instanceV1 = other as InstanceV1? + return instanceV1?.uri.equals(uri) } } diff --git a/app/src/main/java/app/pachli/entity/Translation.kt b/app/src/main/java/app/pachli/entity/Translation.kt new file mode 100644 index 000000000..c964034dc --- /dev/null +++ b/app/src/main/java/app/pachli/entity/Translation.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2023 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.entity + +import com.google.gson.annotations.SerializedName + +/** https://docs.joinmastodon.org/entities/Translation/ */ +data class Translation( + /** The translated text of the status (HTML), equivalent to [Status.content] */ + val content: String, + + /** + * The language of the source text, as auto-detected by the machine translation + * (ISO 639 language code) + */ + @SerializedName("detected_source_language") val detectedSourceLanguage: String, + + /** + * The translated spoiler text of the status (text), if it exists, equivalent to + * [Status.spoilerText] + */ + // Not documented, see https://github.com/mastodon/documentation/issues/1248 + @SerializedName("spoiler_text") val spoilerText: String, + + /** The translated poll (if it exists) */ + // Not documented, see https://github.com/mastodon/documentation/issues/1248 + val poll: TranslatedPoll?, + + /** + * Translated descriptions for media attachments, if any were attached. Other metadata has + * to be determined from the original attachment. + */ + // Not documented, see https://github.com/mastodon/documentation/issues/1248 + @SerializedName("media_attachments") val attachments: List, + + /** The service that provided the machine translation */ + val provider: String, +) + +/** + * A translated poll. Does not contain all the poll data, only the translated text. + * Vote counts and other metadata has to be determined from the original poll object. + */ +data class TranslatedPoll( + val id: String, + val options: List, +) + +/** A translated poll option. */ +data class TranslatedPollOption( + val title: String, +) + +/** A translated attachment. Only the description is translated */ +data class TranslatedAttachment( + val id: String, + val description: String, +) diff --git a/app/src/main/java/app/pachli/fragment/SFragment.kt b/app/src/main/java/app/pachli/fragment/SFragment.kt index 5fd9d303d..978d02290 100644 --- a/app/src/main/java/app/pachli/fragment/SFragment.kt +++ b/app/src/main/java/app/pachli/fragment/SFragment.kt @@ -25,6 +25,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build +import android.os.Bundle import android.os.Environment import android.view.MenuItem import android.view.View @@ -33,7 +34,9 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityOptionsCompat import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import app.pachli.BaseActivity import app.pachli.BottomSheetActivity import app.pachli.PostLookupFallbackBehavior @@ -50,14 +53,19 @@ import app.pachli.entity.Attachment import app.pachli.entity.Status import app.pachli.interfaces.AccountSelectionListener import app.pachli.network.MastodonApi +import app.pachli.network.ServerCapabilitiesRepository +import app.pachli.network.ServerOperation import app.pachli.usecase.TimelineCases import app.pachli.util.openLink import app.pachli.util.parseAsMastodonHtml import app.pachli.view.showMuteAccountDialog import app.pachli.viewdata.AttachmentViewData +import app.pachli.viewdata.StatusViewData +import app.pachli.viewdata.TranslationState import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onFailure import com.google.android.material.snackbar.Snackbar +import io.github.z4kn4fein.semver.constraints.toConstraint import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -82,6 +90,11 @@ abstract class SFragment : Fragment() { @Inject lateinit var timelineCases: TimelineCases + @Inject + lateinit var serverCapabilitiesRepository: ServerCapabilitiesRepository + + private var serverCanTranslate = false + override fun startActivity(intent: Intent) { super.startActivity(intent) requireActivity().overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) @@ -96,6 +109,21 @@ abstract class SFragment : Fragment() { } } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + serverCapabilitiesRepository.flow.collect { + serverCanTranslate = it.can( + ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE, + ">=1.0".toConstraint(), + ) + } + } + } + } + protected fun openReblog(status: Status?) { if (status == null) return bottomSheetActivity.viewAccount(status.account.id) @@ -140,8 +168,13 @@ abstract class SFragment : Fragment() { requireActivity().startActivity(intent) } - protected fun more(status: Status, view: View, position: Int) { - val id = status.actionableId + /** + * Handles the user clicking the "..." (more) button typically at the bottom-right of + * the status. + */ + protected fun more(statusViewData: StatusViewData, view: View, position: Int) { + val status = statusViewData.status + val actionableId = status.actionableId val accountId = status.actionableStatus.account.id val accountUsername = status.actionableStatus.account.username val statusUrl = status.actionableStatus.url @@ -170,6 +203,13 @@ abstract class SFragment : Fragment() { } else { popup.inflate(R.menu.status_more) popup.menu.findItem(R.id.status_download_media).isVisible = status.attachments.isNotEmpty() + if (serverCanTranslate && canTranslate() && status.visibility != Status.Visibility.PRIVATE && status.visibility != Status.Visibility.DIRECT) { + popup.menu.findItem(R.id.status_translate).isVisible = statusViewData.translationState == TranslationState.SHOW_ORIGINAL + popup.menu.findItem(R.id.status_translate_undo).isVisible = statusViewData.translationState == TranslationState.SHOW_TRANSLATION + } else { + popup.menu.findItem(R.id.status_translate).isVisible = false + popup.menu.findItem(R.id.status_translate_undo).isVisible = false + } } val menu = popup.menu val openAsItem = menu.findItem(R.id.status_open_as) @@ -249,7 +289,7 @@ abstract class SFragment : Fragment() { return@setOnMenuItemClickListener true } R.id.status_report -> { - openReportPage(accountId, accountUsername, id) + openReportPage(accountId, accountUsername, actionableId) return@setOnMenuItemClickListener true } R.id.status_unreblog_private -> { @@ -261,15 +301,15 @@ abstract class SFragment : Fragment() { return@setOnMenuItemClickListener true } R.id.status_delete -> { - showConfirmDeleteDialog(id, position) + showConfirmDeleteDialog(actionableId, position) return@setOnMenuItemClickListener true } R.id.status_delete_and_redraft -> { - showConfirmEditDialog(id, position, status) + showConfirmEditDialog(actionableId, position, status) return@setOnMenuItemClickListener true } R.id.status_edit -> { - editStatus(id, status) + editStatus(actionableId, status) return@setOnMenuItemClickListener true } R.id.pin -> { @@ -288,12 +328,31 @@ abstract class SFragment : Fragment() { } return@setOnMenuItemClickListener true } + R.id.status_translate -> { + onTranslate(statusViewData) + return@setOnMenuItemClickListener true + } + R.id.status_translate_undo -> { + onTranslateUndo(statusViewData) + return@setOnMenuItemClickListener true + } } false } popup.show() } + /** + * True if this class can translate statuses (assuming the server can). Superclasses should + * override this if they support translating a status, and also override [onTranslate] + * and [onTranslateUndo]. + */ + open fun canTranslate() = false + + open fun onTranslate(statusViewData: StatusViewData) {} + + open fun onTranslateUndo(statusViewData: StatusViewData) {} + private fun onMute(accountId: String, accountUsername: String) { showMuteAccountDialog(this.requireActivity(), accountUsername) { notifications: Boolean?, duration: Int? -> lifecycleScope.launch { diff --git a/app/src/main/java/app/pachli/network/MastodonApi.kt b/app/src/main/java/app/pachli/network/MastodonApi.kt index bd67cdc61..0cb88f195 100644 --- a/app/src/main/java/app/pachli/network/MastodonApi.kt +++ b/app/src/main/java/app/pachli/network/MastodonApi.kt @@ -28,7 +28,7 @@ import app.pachli.entity.Filter import app.pachli.entity.FilterKeyword import app.pachli.entity.FilterV1 import app.pachli.entity.HashTag -import app.pachli.entity.Instance +import app.pachli.entity.InstanceV1 import app.pachli.entity.Marker import app.pachli.entity.MastoList import app.pachli.entity.MediaUploadResult @@ -44,8 +44,10 @@ import app.pachli.entity.StatusContext import app.pachli.entity.StatusEdit import app.pachli.entity.StatusSource import app.pachli.entity.TimelineAccount +import app.pachli.entity.Translation import app.pachli.entity.TrendingTag import app.pachli.entity.TrendsLink +import app.pachli.network.model.InstanceV2 import app.pachli.util.HttpHeaderLink import at.connyduck.calladapter.networkresult.NetworkResult import okhttp3.MultipartBody @@ -103,7 +105,10 @@ interface MastodonApi { suspend fun getCustomEmojis(): NetworkResult> @GET("api/v1/instance") - suspend fun getInstance(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult + suspend fun getInstanceV1(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult + + @GET("api/v2/instance") + suspend fun getInstanceV2(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult @GET("api/v1/filters") suspend fun getFiltersV1(): NetworkResult> @@ -311,6 +316,11 @@ interface MastodonApi { @Path("id") statusId: String, ): NetworkResult + @POST("api/v1/statuses/{id}/translate") + suspend fun translate( + @Path("id") statusId: String, + ): NetworkResult + @GET("api/v1/scheduled_statuses") suspend fun scheduledStatuses( @Query("limit") limit: Int? = null, diff --git a/app/src/main/java/app/pachli/network/Operations.kt b/app/src/main/java/app/pachli/network/Operations.kt new file mode 100644 index 000000000..214f1b972 --- /dev/null +++ b/app/src/main/java/app/pachli/network/Operations.kt @@ -0,0 +1,181 @@ +/* + * Copyright 2023 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.network + +import app.pachli.entity.InstanceV1 +import app.pachli.network.ServerKind.MASTODON +import app.pachli.network.model.InstanceV2 +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.binding +import com.github.michaelbull.result.getError +import com.github.michaelbull.result.getOr +import com.github.michaelbull.result.mapError +import io.github.z4kn4fein.semver.Version +import io.github.z4kn4fein.semver.constraints.Constraint +import io.github.z4kn4fein.semver.constraints.satisfiedByAny +import kotlin.collections.set +import kotlin.coroutines.cancellation.CancellationException + +/** + * Identifiers for operations that the server may or may not support. + */ +enum class ServerOperation(id: String) { + // Translate a status, introduced in Mastodon 4.0.0 + ORG_JOINMASTODON_STATUSES_TRANSLATE("org.joinmastodon.statuses.translate"), +} + +enum class ServerKind { + MASTODON, + AKKOMA, + PLEROMA, + UNKNOWN, + + ; + + companion object { + private val rxVersion = """\(compatible; ([^ ]+) ([^)]+)\)""".toRegex() + + fun parse(vs: String): Result, ServerCapabilitiesError> = binding { + // Parse instance version, which looks like "4.2.1 (compatible; Iceshrimp 2023.11)" + // or it's a regular version number. + val matchResult = rxVersion.find(vs) + if (matchResult == null) { + val version = resultOf { + Version.parse(vs, strict = false) + }.mapError { ServerCapabilitiesError.VersionParse(it) }.bind() + return@binding Pair(MASTODON, version) + } + + val (software, unparsedVersion) = matchResult.destructured + val version = resultOf { + Version.parse(unparsedVersion, strict = false) + }.mapError { ServerCapabilitiesError.VersionParse(it) }.bind() + + val s = when (software.lowercase()) { + "akkoma" -> AKKOMA + "mastodon" -> MASTODON + "pleroma" -> PLEROMA + else -> UNKNOWN + } + + return@binding Pair(s, version) + } + } +} + +/** Errors that can occur when processing server capabilities */ +sealed interface ServerCapabilitiesError { + val throwable: Throwable + + /** Could not parse the server's version string */ + data class VersionParse(override val throwable: Throwable) : ServerCapabilitiesError +} + +/** Represents operations that can be performed on the given server. */ +class ServerCapabilities( + val serverKind: ServerKind = MASTODON, + private val capabilities: Map> = emptyMap(), +) { + /** + * Returns true if the server supports the given operation at the given minimum version + * level, false otherwise. + */ + fun can(operation: ServerOperation, constraint: Constraint) = capabilities[operation]?.let { + versions -> + constraint satisfiedByAny versions + } ?: false + + companion object { + /** + * Generates [ServerCapabilities] from the instance's configuration report. + */ + fun from(instance: InstanceV1): Result = binding { + val (serverKind, _) = ServerKind.parse(instance.version).bind() + val capabilities = mutableMapOf>() + + // Create a default set of capabilities (empty). Mastodon servers support InstanceV2 from + // v4.0.0 onwards, and there's no information about capabilities for other server kinds. + + ServerCapabilities(serverKind, capabilities) + } + + /** + * Generates [ServerCapabilities] from the instance's configuration report. + */ + fun from(instance: InstanceV2): Result = binding { + val (serverKind, _) = ServerKind.parse(instance.version).bind() + val capabilities = mutableMapOf>() + + when (serverKind) { + MASTODON -> { + if (instance.configuration.translation.enabled) { + capabilities[ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE] = listOf(Version(major = 1)) + } + } + else -> { /* nothing to do yet */ } + } + + ServerCapabilities(serverKind, capabilities) + } + } +} + +// See https://www.jacobras.nl/2022/04/resilient-use-cases-with-kotlin-result-coroutines-and-annotations/ + +/** + * Like [runCatching], but with proper coroutines cancellation handling. Also only catches [Exception] instead of [Throwable]. + * + * Cancellation exceptions need to be rethrown. See https://github.com/Kotlin/kotlinx.coroutines/issues/1814. + */ +inline fun resultOf(block: () -> R): Result { + return try { + Ok(block()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Err(e) + } +} + +/** + * Like [runCatching], but with proper coroutines cancellation handling. Also only catches [Exception] instead of [Throwable]. + * + * Cancellation exceptions need to be rethrown. See https://github.com/Kotlin/kotlinx.coroutines/issues/1814. + */ +inline fun T.resultOf(block: T.() -> R): Result { + return try { + Ok(block()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Err(e) + } +} + +/** + * Like [mapCatching], but uses [resultOf] instead of [runCatching]. + */ +inline fun Result.mapResult(transform: (value: T) -> R): Result { + val successResult = getOr { null } // getOrNull() + return when { + successResult != null -> resultOf { transform(successResult) } + else -> Err(getError() ?: error("Unreachable state")) + } +} diff --git a/app/src/main/java/app/pachli/network/ServerCapabilitiesRepository.kt b/app/src/main/java/app/pachli/network/ServerCapabilitiesRepository.kt new file mode 100644 index 000000000..02c61c03a --- /dev/null +++ b/app/src/main/java/app/pachli/network/ServerCapabilitiesRepository.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2023 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.network + +import app.pachli.db.AccountManager +import app.pachli.di.ApplicationScope +import at.connyduck.calladapter.networkresult.fold +import com.github.michaelbull.result.getOr +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ServerCapabilitiesRepository @Inject constructor( + private val mastodonApi: MastodonApi, + private val accountManager: AccountManager, + @ApplicationScope private val externalScope: CoroutineScope, +) { + private val _flow = MutableStateFlow(ServerCapabilities()) + val flow = _flow.asStateFlow() + + init { + externalScope.launch { + accountManager.activeAccountFlow.collect { + _flow.emit(getCapabilities()) + } + } + } + + /** + * Returns the capabilities of the current server. If the capabilties cannot be + * determined then a default set of capabilities that all servers are expected + * to support is returned. + */ + private suspend fun getCapabilities(): ServerCapabilities { + return mastodonApi.getInstanceV2().fold( + { instance -> ServerCapabilities.from(instance).getOr { null } }, + { + mastodonApi.getInstanceV1().fold({ instance -> + ServerCapabilities.from(instance).getOr { null } + }, { null },) + }, + ) ?: ServerCapabilities() + } +} diff --git a/app/src/main/java/app/pachli/network/model/InstanceV2.kt b/app/src/main/java/app/pachli/network/model/InstanceV2.kt new file mode 100644 index 000000000..952b8b45e --- /dev/null +++ b/app/src/main/java/app/pachli/network/model/InstanceV2.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2023 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.network.model + +import app.pachli.entity.Account +import com.google.gson.annotations.SerializedName + +/** https://docs.joinmastodon.org/entities/Instance/ */ +data class InstanceV2( + /** The domain name of the instance */ + val domain: String, + + /** The title of the website */ + val title: String, + + /** The version of Mastodon installed on the instance */ + val version: String, + + /** + * The URL for the source code of the software running on this instance, in keeping with AGPL + * license requirements. + */ + @SerializedName("source_url") val sourceUrl: String, + + /** A short, plain-text description defined by the admin. */ + val description: String, + + /** Usage data for this instance. */ + val usage: Usage, + + /** An image used to represent this instance. */ + val thumbnail: Thumbnail, + + /** Primary languages of the website and its staff (ISD 639-1 2 letter language codes) */ + val languages: List, + + /** Configured values and limits for this website. */ + val configuration: Configuration, + + /** Information about registering for this website. */ + val registrations: Registrations, + + /** Hints related to contacting a representative of the website. */ + val contact: Contact, + + /** An itemized list of rules for this website. */ + val rules: List, +) + +data class Usage( + /** Usage data related to users on this instance. */ + val users: Users, +) + +data class Users( + /** The number of active users in the past 4 weeks. */ + val activeMonth: Int = 0, +) + +data class Thumbnail( + /** The URL for the thumbnail image. */ + val url: String, + + /** + * A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails + * when media has not been downloaded yet. + */ + val blurhash: String?, + + /** Links to scaled resolution images, for high DPI screens. */ + val versions: ThumbnailVersions?, +) + +data class ThumbnailVersions( + /** The URL for the thumbnail image at 1x resolution. */ + @SerializedName("@1x") val oneX: String?, + + /** The URL for the thumbnail image at 2x resolution. */ + @SerializedName("@2x") val twoX: String?, +) + +data class Configuration( + /** URLs of interest for clients apps. */ + val urls: InstanceV2Urls, + + /** Limits related to accounts. */ + val accounts: InstanceV2Accounts, + + /** Limits related to authoring statuses. */ + val statuses: InstanceV2Statuses, + + /** Hints for which attachments will be accepted. */ + @SerializedName("media_attachments") val mediaAttachments: MediaAttachments, + + /** Limits related to polls. */ + val polls: InstanceV2Polls, + + /** Hints related to translation. */ + val translation: InstanceV2Translation, +) + +data class InstanceV2Urls( + /** The Websockets URL for connecting to the streaming API. */ + @SerializedName("streaming_api") val streamingApi: String, +) + +data class InstanceV2Accounts( + /** The maximum number of featured tags allowed for each account. */ + @SerializedName("max_featured_tags") val maxFeaturedTags: Int, +) + +data class InstanceV2Statuses( + /** The maximum number of allowed characters per status. */ + @SerializedName("max_characters") val maxCharacters: Int, + + /** The maximum number of media attachments that can be added to a status. */ + @SerializedName("max_media_attachments") val maxMediaAttachments: Int, + + /** Each URL in a status will be assumed to be exactly this many characters. */ + @SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int, +) + +data class MediaAttachments( + /** Contains MIME types that can be uploaded. */ + @SerializedName("supported_mime_types") val supportedMimeTypes: List, + + /** The maximum size of any uploaded image, in bytes. */ + @SerializedName("image_size_limit") val imageSizeLimit: Int, + + /** The maximum number of pixels (width times height) for image uploads. */ + @SerializedName("image_matrix_limit") val imageMatrixLimit: Int, + + /** The maximum size of any uploaded video, in bytes. */ + @SerializedName("video_size_limit") val videoSizeLimit: Int, + + /** The maximum frame rate for any uploaded video. */ + @SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int, + + /** The maximum number of pixels (width times height) for video uploads. */ + @SerializedName("video_matrix_limit") val videoMatrixLimit: Int, +) + +data class InstanceV2Polls( + /** Each poll is allowed to have up to this many options. */ + @SerializedName("max_options") val maxOptions: Int, + + /** Each poll option is allowed to have this many characters. */ + @SerializedName("max_characters_per_option") val maxCharactersPerOption: Int, + + /** The shortest allowed poll duration, in seconds. */ + @SerializedName("min_expiration") val minExpiration: Int, + + /** The longest allowed poll duration, in seconds. */ + @SerializedName("max_expiration") val maxExpiration: Int, +) + +data class InstanceV2Translation( + /** Whether the Translations API is available on this instance. */ + val enabled: Boolean, +) + +data class Registrations( + /** Whether registrations are enabled. */ + val enabled: Boolean, + + /** Whether registrations require moderator approval. */ + @SerializedName("approval_required") val approvalRequired: Boolean, + + /** A custom message to be shown when registrations are closed. */ + val message: String?, +) + +data class Contact( + /** An email address that can be messaged regarding inquiries or issues. */ + val email: String, + + /** An account that can be contacted natively over the network regarding inquiries or issues. */ + val account: Account, +) + +/** https://docs.joinmastodon.org/entities/Rule/ */ +data class Rule( + /** An identifier for the rule. */ + val id: String, + + /** The rule to be followed. */ + val text: String, +) diff --git a/app/src/main/java/app/pachli/usecase/TimelineCases.kt b/app/src/main/java/app/pachli/usecase/TimelineCases.kt index 45e77c2f8..aa82ad6ec 100644 --- a/app/src/main/java/app/pachli/usecase/TimelineCases.kt +++ b/app/src/main/java/app/pachli/usecase/TimelineCases.kt @@ -26,12 +26,15 @@ import app.pachli.appstore.PinEvent import app.pachli.appstore.PollVoteEvent import app.pachli.appstore.ReblogEvent import app.pachli.appstore.StatusDeletedEvent +import app.pachli.components.timeline.CachedTimelineRepository import app.pachli.entity.DeletedStatus import app.pachli.entity.Poll import app.pachli.entity.Relationship import app.pachli.entity.Status +import app.pachli.entity.Translation import app.pachli.network.MastodonApi import app.pachli.util.getServerErrorMessage +import app.pachli.viewdata.StatusViewData import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onFailure @@ -42,6 +45,7 @@ import javax.inject.Inject class TimelineCases @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub, + private val cachedTimelineRepository: CachedTimelineRepository, ) { suspend fun reblog(statusId: String, reblog: Boolean): NetworkResult { @@ -139,6 +143,18 @@ class TimelineCases @Inject constructor( suspend fun rejectFollowRequest(accountId: String): NetworkResult { return mastodonApi.rejectFollowRequest(accountId) } + + suspend fun translate(statusViewData: StatusViewData): NetworkResult { + return cachedTimelineRepository.translate(statusViewData) + } + + suspend fun translateUndo(statusViewData: StatusViewData) { + cachedTimelineRepository.translateUndo(statusViewData) + } + + companion object { + private const val TAG = "TimelineCases" + } } class TimelineError(message: String?) : RuntimeException(message) diff --git a/app/src/main/java/app/pachli/util/StatusDisplayOptions.kt b/app/src/main/java/app/pachli/util/StatusDisplayOptions.kt index 5ac8a9d15..8a6b99df9 100644 --- a/app/src/main/java/app/pachli/util/StatusDisplayOptions.kt +++ b/app/src/main/java/app/pachli/util/StatusDisplayOptions.kt @@ -44,4 +44,6 @@ data class StatusDisplayOptions( val showSensitiveMedia: Boolean = false, @get:JvmName("openSpoiler") val openSpoiler: Boolean = false, + @get:JvmName("canTranslate") + val canTranslate: Boolean = false, ) diff --git a/app/src/main/java/app/pachli/util/StatusDisplayOptionsRepository.kt b/app/src/main/java/app/pachli/util/StatusDisplayOptionsRepository.kt index 0a59698f9..c31cda604 100644 --- a/app/src/main/java/app/pachli/util/StatusDisplayOptionsRepository.kt +++ b/app/src/main/java/app/pachli/util/StatusDisplayOptionsRepository.kt @@ -22,8 +22,11 @@ import androidx.annotation.VisibleForTesting.Companion.PRIVATE import app.pachli.db.AccountEntity import app.pachli.db.AccountManager import app.pachli.di.ApplicationScope +import app.pachli.network.ServerCapabilitiesRepository +import app.pachli.network.ServerOperation import app.pachli.settings.AccountPreferenceDataStore import app.pachli.settings.PrefKeys +import io.github.z4kn4fein.semver.constraints.toConstraint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -43,6 +46,7 @@ import javax.inject.Singleton @Singleton class StatusDisplayOptionsRepository @Inject constructor( private val sharedPreferencesRepository: SharedPreferencesRepository, + private val serverCapabilitiesRepository: ServerCapabilitiesRepository, private val accountManager: AccountManager, private val accountPreferenceDataStore: AccountPreferenceDataStore, @ApplicationScope private val externalScope: CoroutineScope, @@ -146,6 +150,17 @@ class StatusDisplayOptionsRepository @Inject constructor( } } } + + externalScope.launch { + serverCapabilitiesRepository.flow.collect { serverCapabilities -> + Timber.d("Updating because server capabilities changed") + _flow.update { + it.copy( + canTranslate = serverCapabilities.can(ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE, ">=1.0".toConstraint()), + ) + } + } + } } @VisibleForTesting(otherwise = PRIVATE) @@ -168,6 +183,7 @@ class StatusDisplayOptionsRepository @Inject constructor( showStatsInline = sharedPreferencesRepository.getBoolean(PrefKeys.SHOW_STATS_INLINE, default.showStatsInline), showSensitiveMedia = account?.alwaysShowSensitiveMedia ?: default.showSensitiveMedia, openSpoiler = account?.alwaysOpenSpoiler ?: default.openSpoiler, + canTranslate = default.canTranslate, ) } } diff --git a/app/src/main/java/app/pachli/view/PollView.kt b/app/src/main/java/app/pachli/view/PollView.kt index 5affc72af..07a5abe55 100644 --- a/app/src/main/java/app/pachli/view/PollView.kt +++ b/app/src/main/java/app/pachli/view/PollView.kt @@ -21,6 +21,7 @@ import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater +import android.view.View import android.widget.LinearLayout import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager @@ -81,13 +82,45 @@ class PollView @JvmOverloads constructor( absoluteTimeFormatter: AbsoluteTimeFormatter, listener: OnClickListener, ) { - val adapter = PollAdapter() + val now = System.currentTimeMillis() + var displayMode: PollAdapter.DisplayMode = PollAdapter.DisplayMode.RESULT + var resultClickListener: View.OnClickListener? = null + var pollOptionClickListener: View.OnClickListener? = null + + // Translated? Create new options from old, using the translated title + val options = pollViewData.translatedPoll?.let { + it.options.zip(pollViewData.options) { t, o -> + o.copy(title = t.title) + } + } ?: pollViewData.options + + val canVote = !(pollViewData.expired(now) || pollViewData.voted) + if (canVote) { + pollOptionClickListener = View.OnClickListener { + binding.statusPollButton.isEnabled = options.firstOrNull { it.selected } != null + } + displayMode = if (pollViewData.multiple) PollAdapter.DisplayMode.MULTIPLE_CHOICE else PollAdapter.DisplayMode.SINGLE_CHOICE + } else { + resultClickListener = View.OnClickListener { listener.onClick(null) } + binding.statusPollButton.hide() + } + + val adapter = PollAdapter( + options = options, + votesCount = pollViewData.votesCount, + votersCount = pollViewData.votersCount, + emojis = emojis, + animateEmojis = statusDisplayOptions.animateEmojis, + displayMode = displayMode, + enabled = true, + resultClickListener = resultClickListener, + pollOptionClickListener = pollOptionClickListener, + ) + binding.statusPollOptions.adapter = adapter binding.statusPollOptions.layoutManager = LinearLayoutManager(context) (binding.statusPollOptions.itemAnimator as? DefaultItemAnimator)?.supportsChangeAnimations = false - val now = System.currentTimeMillis() - binding.statusPollOptions.show() binding.statusPollDescription.text = getPollInfoText( @@ -99,37 +132,10 @@ class PollView @JvmOverloads constructor( ) binding.statusPollDescription.show() - val expired = pollViewData.expired || ((pollViewData.expiresAt != null) && (now > pollViewData.expiresAt.time)) - // Poll expired or already voted, can't vote now - if (expired || pollViewData.voted) { - adapter.setup( - pollViewData.options, - pollViewData.votesCount, - pollViewData.votersCount, - emojis, - PollAdapter.RESULT, - { listener.onClick(null) }, - statusDisplayOptions.animateEmojis, - ) - binding.statusPollButton.hide() - return - } - - // Active poll, can vote - adapter.setup( - pollViewData.options, - pollViewData.votesCount, - pollViewData.votersCount, - emojis, - if (pollViewData.multiple) PollAdapter.MULTIPLE else PollAdapter.SINGLE, - null, - statusDisplayOptions.animateEmojis, - true, - ) { - binding.statusPollButton.isEnabled = adapter.getSelected().isNotEmpty() - } + if (!canVote) return + // Set up voting binding.statusPollButton.show() binding.statusPollButton.isEnabled = false binding.statusPollButton.setOnClickListener { diff --git a/app/src/main/java/app/pachli/viewdata/PollViewData.kt b/app/src/main/java/app/pachli/viewdata/PollViewData.kt index 67ecc8945..09e76bfe0 100644 --- a/app/src/main/java/app/pachli/viewdata/PollViewData.kt +++ b/app/src/main/java/app/pachli/viewdata/PollViewData.kt @@ -23,6 +23,7 @@ import androidx.core.text.parseAsHtml import app.pachli.R import app.pachli.entity.Poll import app.pachli.entity.PollOption +import app.pachli.entity.TranslatedPoll import java.util.Date import kotlin.math.roundToInt @@ -35,7 +36,15 @@ data class PollViewData( val votersCount: Int?, val options: List, var voted: Boolean, + val translatedPoll: TranslatedPoll?, ) { + /** + * @param timeInMs A timestamp in milliseconds-since-the-epoch + * @return true if this poll is either marked as expired, or [timeInMs] is after this poll's + * expiry time. + */ + fun expired(timeInMs: Long) = expired || ((expiresAt != null) && (timeInMs > expiresAt.time)) + companion object { fun from(poll: Poll) = PollViewData( id = poll.id, @@ -46,6 +55,7 @@ data class PollViewData( votersCount = poll.votersCount, options = poll.options.mapIndexed { index, option -> PollOptionViewData.from(option, poll.ownVotes?.contains(index) == true) }, voted = poll.voted, + translatedPoll = null, ) } } diff --git a/app/src/main/java/app/pachli/viewdata/StatusViewData.kt b/app/src/main/java/app/pachli/viewdata/StatusViewData.kt index 6107369af..b6bffc455 100644 --- a/app/src/main/java/app/pachli/viewdata/StatusViewData.kt +++ b/app/src/main/java/app/pachli/viewdata/StatusViewData.kt @@ -17,9 +17,11 @@ package app.pachli.viewdata import android.os.Build import android.text.Spanned +import android.text.SpannedString import app.pachli.components.conversation.ConversationAccountEntity import app.pachli.components.conversation.ConversationStatusEntity import app.pachli.db.TimelineStatusWithAccount +import app.pachli.db.TranslatedStatusEntity import app.pachli.entity.Filter import app.pachli.entity.Poll import app.pachli.entity.Status @@ -28,11 +30,24 @@ import app.pachli.util.replaceCrashingCharacters import app.pachli.util.shouldTrimStatus import com.google.gson.Gson +enum class TranslationState { + /** Show the original, untranslated status */ + SHOW_ORIGINAL, + + /** Show the original, untranslated status, but translation is happening */ + TRANSLATING, + + /** Show the translated status */ + SHOW_TRANSLATION, +} + /** * Data required to display a status. */ data class StatusViewData( var status: Status, + var translation: TranslatedStatusEntity? = null, + /** * If the status includes a non-empty content warning ([spoilerText]), specifies whether * just the content warning is showing (false), or the whole status content is showing (true). @@ -67,6 +82,9 @@ data class StatusViewData( // if the Filter.Action class subtypes carried the FilterResult information with them, // and it's impossible to construct them with an empty list. var filterAction: Filter.Action = Filter.Action.NONE, + + /** True if the translated content should be shown (if it exists) */ + val translationState: TranslationState, ) { val id: String get() = status.id @@ -79,10 +97,20 @@ data class StatusViewData( */ val isCollapsible: Boolean + private val _content: Spanned + + private val _translatedContent: Spanned + val content: Spanned + get() = if (translationState == TranslationState.SHOW_TRANSLATION) _translatedContent else _content + + private val _spoilerText: String + private val _translatedSpoilerText: String /** The content warning, may be the empty string */ val spoilerText: String + get() = if (translationState == TranslationState.SHOW_TRANSLATION) _translatedSpoilerText else _spoilerText + val username: String val actionable: Status @@ -104,14 +132,22 @@ data class StatusViewData( init { if (Build.VERSION.SDK_INT == 23) { // https://github.com/tuskyapp/Tusky/issues/563 - this.content = replaceCrashingCharacters(status.actionableStatus.content.parseAsMastodonHtml()) - this.spoilerText = + this._content = replaceCrashingCharacters(status.actionableStatus.content.parseAsMastodonHtml()) + this._spoilerText = replaceCrashingCharacters(status.actionableStatus.spoilerText).toString() this.username = replaceCrashingCharacters(status.actionableStatus.account.username).toString() + this._translatedContent = translation?.content?.let { + replaceCrashingCharacters(it.parseAsMastodonHtml()) + } ?: SpannedString("") + this._translatedSpoilerText = translation?.spoilerText?.let { + replaceCrashingCharacters(it).toString() + } ?: "" } else { - this.content = status.actionableStatus.content.parseAsMastodonHtml() - this.spoilerText = status.actionableStatus.spoilerText + this._content = status.actionableStatus.content.parseAsMastodonHtml() + this._translatedContent = translation?.content?.parseAsMastodonHtml() ?: SpannedString("") + this._spoilerText = status.actionableStatus.spoilerText + this._translatedSpoilerText = translation?.spoilerText ?: "" this.username = status.actionableStatus.account.username } this.isCollapsible = shouldTrimStatus(this.content) @@ -163,6 +199,8 @@ data class StatusViewData( isCollapsed: Boolean, isDetailed: Boolean = false, filterAction: Filter.Action = Filter.Action.NONE, + translationState: TranslationState = TranslationState.SHOW_ORIGINAL, + translation: TranslatedStatusEntity? = null, ) = StatusViewData( status = status, isShowingContent = isShowingContent, @@ -170,6 +208,8 @@ data class StatusViewData( isExpanded = isExpanded, isDetailed = isDetailed, filterAction = filterAction, + translationState = translationState, + translation = translation, ) fun from(conversationStatusEntity: ConversationStatusEntity) = StatusViewData( @@ -207,6 +247,7 @@ data class StatusViewData( isExpanded = conversationStatusEntity.expanded, isShowingContent = conversationStatusEntity.showingHiddenContent, isCollapsed = conversationStatusEntity.collapsed, + translationState = TranslationState.SHOW_ORIGINAL, // TODO: Include this in conversationStatusEntity ) fun from( @@ -215,14 +256,17 @@ data class StatusViewData( isExpanded: Boolean, isShowingContent: Boolean, isDetailed: Boolean = false, + translationState: TranslationState = TranslationState.SHOW_ORIGINAL, ): StatusViewData { val status = timelineStatusWithAccount.toStatus(gson) return StatusViewData( status = status, + translation = timelineStatusWithAccount.translatedStatus, isExpanded = timelineStatusWithAccount.viewData?.expanded ?: isExpanded, isShowingContent = timelineStatusWithAccount.viewData?.contentShowing ?: (isShowingContent || !status.actionableStatus.sensitive), isCollapsed = timelineStatusWithAccount.viewData?.contentCollapsed ?: true, isDetailed = isDetailed, + translationState = timelineStatusWithAccount.viewData?.translationState ?: translationState, ) } } diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml index d11ecca4d..606ec3409 100644 --- a/app/src/main/res/layout/item_status.xml +++ b/app/src/main/res/layout/item_status.xml @@ -215,6 +215,20 @@ app:layout_constraintTop_toBottomOf="@id/status_media_preview_container" tools:visibility="gone" /> + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee34ab54b..d92961ec4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -206,6 +206,8 @@ Dismiss Details add reaction + Translate + Undo translate Hashtags Mentions @@ -793,6 +795,7 @@ Favoriting post failed: %s Boosting post failed: %s Voting in poll failed: %s + Translation failed: %s Accepting follow request failed: %s Rejecting follow request failed: %s Loading filters failed: %s @@ -828,7 +831,6 @@ Delete filter \'%1$s\'?" Delete Do you want to save your profile changes? - %1$s %2$d Software updates @@ -839,4 +841,7 @@ An update is available Don\'t remind me for this version Never remind me + + Translating… + %1$s diff --git a/app/src/test/java/app/pachli/StatusComparisonTest.kt b/app/src/test/java/app/pachli/StatusComparisonTest.kt index 624ef8dd5..f1ac63270 100644 --- a/app/src/test/java/app/pachli/StatusComparisonTest.kt +++ b/app/src/test/java/app/pachli/StatusComparisonTest.kt @@ -3,6 +3,7 @@ package app.pachli import androidx.test.ext.junit.runners.AndroidJUnit4 import app.pachli.entity.Status import app.pachli.viewdata.StatusViewData +import app.pachli.viewdata.TranslationState import com.google.gson.Gson import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals @@ -44,12 +45,14 @@ class StatusComparisonTest { isExpanded = false, isShowingContent = false, isCollapsed = false, + translationState = TranslationState.SHOW_ORIGINAL, ) val viewdata2 = StatusViewData( status = createStatus(), isExpanded = false, isShowingContent = false, isCollapsed = false, + translationState = TranslationState.SHOW_ORIGINAL, ) assertEquals(viewdata1, viewdata2) } @@ -61,12 +64,14 @@ class StatusComparisonTest { isExpanded = true, isShowingContent = false, isCollapsed = false, + translationState = TranslationState.SHOW_ORIGINAL, ) val viewdata2 = StatusViewData( status = createStatus(), isExpanded = false, isShowingContent = false, isCollapsed = false, + translationState = TranslationState.SHOW_ORIGINAL, ) assertNotEquals(viewdata1, viewdata2) } @@ -78,12 +83,14 @@ class StatusComparisonTest { isExpanded = true, isShowingContent = false, isCollapsed = false, + translationState = TranslationState.SHOW_ORIGINAL, ) val viewdata2 = StatusViewData( status = createStatus(), isExpanded = false, isShowingContent = false, isCollapsed = false, + translationState = TranslationState.SHOW_ORIGINAL, ) assertNotEquals(viewdata1, viewdata2) } diff --git a/app/src/test/java/app/pachli/components/compose/ComposeActivityTest.kt b/app/src/test/java/app/pachli/components/compose/ComposeActivityTest.kt index af8cb31aa..a954f5719 100644 --- a/app/src/test/java/app/pachli/components/compose/ComposeActivityTest.kt +++ b/app/src/test/java/app/pachli/components/compose/ComposeActivityTest.kt @@ -25,8 +25,8 @@ import app.pachli.R import app.pachli.components.instanceinfo.InstanceInfoRepository import app.pachli.db.AccountManager import app.pachli.entity.Account -import app.pachli.entity.Instance import app.pachli.entity.InstanceConfiguration +import app.pachli.entity.InstanceV1 import app.pachli.entity.StatusConfiguration import app.pachli.network.MastodonApi import app.pachli.rules.lazyActivityScenarioRule @@ -69,7 +69,7 @@ class ComposeActivityTest { launchActivity = false, ) - private var getInstanceCallback: (() -> Instance)? = null + private var getInstanceCallback: (() -> InstanceV1)? = null @Inject lateinit var mastodonApi: MastodonApi @@ -85,7 +85,7 @@ class ComposeActivityTest { reset(mastodonApi) mastodonApi.stub { onBlocking { getCustomEmojis() } doReturn NetworkResult.success(emptyList()) - onBlocking { getInstance() } doAnswer { + onBlocking { getInstanceV1() } doAnswer { getInstanceCallback?.invoke().let { instance -> if (instance == null) { NetworkResult.failure(Throwable()) @@ -555,8 +555,8 @@ class ComposeActivityTest { activity.findViewById(R.id.composeEditField).setText(text ?: "Some text") } - private fun getInstanceWithCustomConfiguration(maximumLegacyTootCharacters: Int? = null, configuration: InstanceConfiguration? = null): Instance { - return Instance( + private fun getInstanceWithCustomConfiguration(maximumLegacyTootCharacters: Int? = null, configuration: InstanceConfiguration? = null): InstanceV1 { + return InstanceV1( uri = "https://example.token", version = "2.6.3", maxTootChars = maximumLegacyTootCharacters, diff --git a/app/src/test/java/app/pachli/components/instanceinfo/InstanceInfoRepositoryTest.kt b/app/src/test/java/app/pachli/components/instanceinfo/InstanceInfoRepositoryTest.kt index 93406dc44..d78eb8c4f 100644 --- a/app/src/test/java/app/pachli/components/instanceinfo/InstanceInfoRepositoryTest.kt +++ b/app/src/test/java/app/pachli/components/instanceinfo/InstanceInfoRepositoryTest.kt @@ -20,8 +20,8 @@ package app.pachli.components.instanceinfo import app.pachli.db.AccountEntity import app.pachli.db.AccountManager import app.pachli.db.InstanceDao -import app.pachli.entity.Instance import app.pachli.entity.InstanceConfiguration +import app.pachli.entity.InstanceV1 import app.pachli.entity.StatusConfiguration import app.pachli.network.MastodonApi import at.connyduck.calladapter.networkresult.NetworkResult @@ -32,7 +32,7 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock class InstanceInfoRepositoryTest { - private var instanceResponseCallback: (() -> Instance)? = null + private var instanceResponseCallback: (() -> InstanceV1)? = null private var accountManager: AccountManager = mock { on { activeAccount } doReturn AccountEntity( @@ -61,7 +61,7 @@ class InstanceInfoRepositoryTest { private fun setup() { mastodonApi = mock { onBlocking { getCustomEmojis() } doReturn NetworkResult.success(emptyList()) - onBlocking { getInstance() } doReturn instanceResponseCallback?.invoke().let { instance -> + onBlocking { getInstanceV1() } doReturn instanceResponseCallback?.invoke().let { instance -> if (instance == null) { NetworkResult.failure(Throwable()) } else { @@ -121,8 +121,8 @@ class InstanceInfoRepositoryTest { assertEquals(customMaximum * 2, instanceInfo.maxChars) } - private fun getInstanceWithCustomConfiguration(maximumLegacyTootCharacters: Int? = null, configuration: InstanceConfiguration? = null): Instance { - return Instance( + private fun getInstanceWithCustomConfiguration(maximumLegacyTootCharacters: Int? = null, configuration: InstanceConfiguration? = null): InstanceV1 { + return InstanceV1( uri = "https://example.token", version = "2.6.3", maxTootChars = maximumLegacyTootCharacters, diff --git a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt index cee902a18..61098f99f 100644 --- a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt +++ b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt @@ -25,6 +25,8 @@ import app.pachli.components.timeline.MainCoroutineRule import app.pachli.db.AccountEntity import app.pachli.db.AccountManager import app.pachli.fakes.InMemorySharedPreferences +import app.pachli.network.MastodonApi +import app.pachli.network.ServerCapabilitiesRepository import app.pachli.settings.AccountPreferenceDataStore import app.pachli.usecase.TimelineCases import app.pachli.util.SharedPreferencesRepository @@ -36,6 +38,7 @@ import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Before import org.junit.Rule import org.junit.runner.RunWith +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -104,8 +107,20 @@ abstract class NotificationsViewModelTestBase { TestScope(), ) + val mastodonApi: MastodonApi = mock { + onBlocking { getInstanceV2() } doAnswer { null } + onBlocking { getInstanceV1() } doAnswer { null } + } + + val serverCapabilitiesRepository = ServerCapabilitiesRepository( + mastodonApi, + accountManager, + TestScope(), + ) + statusDisplayOptionsRepository = StatusDisplayOptionsRepository( sharedPreferencesRepository, + serverCapabilitiesRepository, accountManager, accountPreferenceDataStore, TestScope(), diff --git a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestStatusAction.kt b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestStatusAction.kt index b3c600d4d..111f8e992 100644 --- a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestStatusAction.kt +++ b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestStatusAction.kt @@ -20,6 +20,7 @@ package app.pachli.components.notifications import app.cash.turbine.test import app.pachli.FilterV1Test.Companion.mockStatus import app.pachli.viewdata.StatusViewData +import app.pachli.viewdata.TranslationState import at.connyduck.calladapter.networkresult.NetworkResult import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.test.runTest @@ -46,6 +47,7 @@ class NotificationsViewModelTestStatusAction : NotificationsViewModelTestBase() isExpanded = true, isShowingContent = false, isCollapsed = false, + translationState = TranslationState.SHOW_ORIGINAL, ) /** Action to bookmark a status */ diff --git a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt index 965809ea0..0ca0ef4a0 100644 --- a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt +++ b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt @@ -26,6 +26,7 @@ import app.pachli.components.timeline.viewmodel.TimelineViewModel import app.pachli.db.AccountManager import app.pachli.entity.Account import app.pachli.network.MastodonApi +import app.pachli.network.ServerCapabilitiesRepository import app.pachli.settings.AccountPreferenceDataStore import app.pachli.usecase.TimelineCases import app.pachli.util.SharedPreferencesRepository @@ -131,8 +132,15 @@ abstract class CachedTimelineViewModelTestBase { timelineCases = mock() + val serverCapabilitiesRepository = ServerCapabilitiesRepository( + mastodonApi, + accountManager, + TestScope(), + ) + statusDisplayOptionsRepository = StatusDisplayOptionsRepository( sharedPreferencesRepository, + serverCapabilitiesRepository, accountManager, accountPreferenceDataStore, TestScope(), diff --git a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestStatusAction.kt b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestStatusAction.kt index 5a2917927..9628f7e7a 100644 --- a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestStatusAction.kt +++ b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestStatusAction.kt @@ -23,6 +23,7 @@ import app.pachli.components.timeline.viewmodel.StatusAction import app.pachli.components.timeline.viewmodel.StatusActionSuccess import app.pachli.components.timeline.viewmodel.UiError import app.pachli.viewdata.StatusViewData +import app.pachli.viewdata.TranslationState import at.connyduck.calladapter.networkresult.NetworkResult import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidTest @@ -53,6 +54,7 @@ class CachedTimelineViewModelTestStatusAction : CachedTimelineViewModelTestBase( isExpanded = true, isShowingContent = false, isCollapsed = false, + translationState = TranslationState.SHOW_ORIGINAL, ) /** Action to bookmark a status */ diff --git a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt b/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt index 713810ccf..7da8beee7 100644 --- a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt +++ b/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt @@ -25,6 +25,7 @@ import app.pachli.components.timeline.viewmodel.TimelineViewModel import app.pachli.db.AccountManager import app.pachli.entity.Account import app.pachli.network.MastodonApi +import app.pachli.network.ServerCapabilitiesRepository import app.pachli.settings.AccountPreferenceDataStore import app.pachli.usecase.TimelineCases import app.pachli.util.SharedPreferencesRepository @@ -123,8 +124,15 @@ abstract class NetworkTimelineViewModelTestBase { timelineCases = mock() + val serverCapabilitiesRepository = ServerCapabilitiesRepository( + mastodonApi, + accountManager, + TestScope(), + ) + statusDisplayOptionsRepository = StatusDisplayOptionsRepository( sharedPreferencesRepository, + serverCapabilitiesRepository, accountManager, accountPreferenceDataStore, TestScope(), diff --git a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestStatusAction.kt b/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestStatusAction.kt index 6e2f5cdb4..eeb5d0778 100644 --- a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestStatusAction.kt +++ b/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestStatusAction.kt @@ -23,6 +23,7 @@ import app.pachli.components.timeline.viewmodel.StatusAction import app.pachli.components.timeline.viewmodel.StatusActionSuccess import app.pachli.components.timeline.viewmodel.UiError import app.pachli.viewdata.StatusViewData +import app.pachli.viewdata.TranslationState import at.connyduck.calladapter.networkresult.NetworkResult import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidTest @@ -53,6 +54,7 @@ class NetworkTimelineViewModelTestStatusAction : NetworkTimelineViewModelTestBas isExpanded = true, isShowingContent = false, isCollapsed = false, + translationState = TranslationState.SHOW_ORIGINAL, ) /** Action to bookmark a status */ diff --git a/app/src/test/java/app/pachli/components/timeline/StatusMocker.kt b/app/src/test/java/app/pachli/components/timeline/StatusMocker.kt index 05a2cdf5f..c38643b26 100644 --- a/app/src/test/java/app/pachli/components/timeline/StatusMocker.kt +++ b/app/src/test/java/app/pachli/components/timeline/StatusMocker.kt @@ -7,6 +7,7 @@ import app.pachli.db.TimelineStatusWithAccount import app.pachli.entity.Status import app.pachli.entity.TimelineAccount import app.pachli.viewdata.StatusViewData +import app.pachli.viewdata.TranslationState import com.google.gson.Gson import java.util.Date @@ -86,6 +87,7 @@ fun mockStatusViewData( isShowingContent = isShowingContent, isCollapsed = isCollapsed, isDetailed = isDetailed, + translationState = TranslationState.SHOW_ORIGINAL, ) fun mockStatusEntityWithAccount( @@ -113,6 +115,7 @@ fun mockStatusEntityWithAccount( expanded = expanded, contentShowing = false, contentCollapsed = true, + translationState = TranslationState.SHOW_ORIGINAL, ), ) } diff --git a/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt b/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt index 86ca8a0e1..b46eefd24 100644 --- a/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt +++ b/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt @@ -19,6 +19,7 @@ import app.pachli.db.TimelineDao import app.pachli.entity.Account import app.pachli.entity.StatusContext import app.pachli.network.MastodonApi +import app.pachli.network.ServerCapabilitiesRepository import app.pachli.settings.AccountPreferenceDataStore import app.pachli.usecase.TimelineCases import app.pachli.util.SharedPreferencesRepository @@ -166,8 +167,15 @@ class ViewThreadViewModelTest { onBlocking { getStatusViewData(any()) } doReturn emptyMap() } + val serverCapabilitiesRepository = ServerCapabilitiesRepository( + mastodonApi, + accountManager, + TestScope(), + ) + statusDisplayOptionsRepository = StatusDisplayOptionsRepository( sharedPreferencesRepository, + serverCapabilitiesRepository, accountManager, accountPreferenceDataStore, TestScope(), diff --git a/app/src/test/java/app/pachli/di/FakeDatabaseModule.kt b/app/src/test/java/app/pachli/di/FakeDatabaseModule.kt index a3e6b175b..9fee3a4e6 100644 --- a/app/src/test/java/app/pachli/di/FakeDatabaseModule.kt +++ b/app/src/test/java/app/pachli/di/FakeDatabaseModule.kt @@ -65,4 +65,7 @@ object FakeDatabaseModule { @Provides fun provideRemoteKeyDao(appDatabase: AppDatabase) = appDatabase.remoteKeyDao() + + @Provides + fun providesTranslatedStatusDao(appDatabase: AppDatabase) = appDatabase.translatedStatusDao() } diff --git a/app/src/test/java/app/pachli/network/InstanceSwitchAuthInterceptorTest.kt b/app/src/test/java/app/pachli/network/InstanceV1SwitchAuthInterceptorTest.kt similarity index 98% rename from app/src/test/java/app/pachli/network/InstanceSwitchAuthInterceptorTest.kt rename to app/src/test/java/app/pachli/network/InstanceV1SwitchAuthInterceptorTest.kt index 6b9163816..80a2039cf 100644 --- a/app/src/test/java/app/pachli/network/InstanceSwitchAuthInterceptorTest.kt +++ b/app/src/test/java/app/pachli/network/InstanceV1SwitchAuthInterceptorTest.kt @@ -14,7 +14,7 @@ import org.junit.Test import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock -class InstanceSwitchAuthInterceptorTest { +class InstanceV1SwitchAuthInterceptorTest { private val mockWebServer = MockWebServer() diff --git a/app/src/test/java/app/pachli/network/ServerKindTest.kt b/app/src/test/java/app/pachli/network/ServerKindTest.kt new file mode 100644 index 000000000..d34959602 --- /dev/null +++ b/app/src/test/java/app/pachli/network/ServerKindTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2023 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.network + +import app.pachli.network.ServerKind.AKKOMA +import app.pachli.network.ServerKind.MASTODON +import app.pachli.network.ServerKind.PLEROMA +import app.pachli.network.ServerKind.UNKNOWN +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import io.github.z4kn4fein.semver.Version +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameters + +@RunWith(Parameterized::class) +class ServerKindTest( + private val input: String, + private val want: Result, ServerCapabilitiesError>, +) { + companion object { + @Parameters(name = "{0}") + @JvmStatic + fun data(): Iterable { + return listOf( + arrayOf( + "4.0.0", + Ok(Pair(MASTODON, Version.parse("4.0.0", strict = false))), + ), + arrayOf( + "4.2.1 (compatible; Iceshrimp 2023.11)", + Ok(Pair(UNKNOWN, Version.parse("2023.11", strict = false))), + ), + arrayOf( + "2.7.2 (compatible; Akkoma 3.10.3-202-g1b838627-1-CI-COMMIT-TAG---)", + Ok(Pair(AKKOMA, Version.parse("3.10.3-202-g1b838627-1-CI-COMMIT-TAG---", strict = false))), + ), + arrayOf( + "2.7.2 (compatible; Pleroma 2.5.54-640-gacbec640.develop+soapbox)", + Ok(Pair(PLEROMA, Version.parse("2.5.54-640-gacbec640.develop+soapbox", strict = false))), + ), + ) + } + } + + @Test + fun `ServerKind parse works`() { + assertEquals(want, ServerKind.parse(input)) + } +} diff --git a/app/src/test/java/app/pachli/usecase/TimelineCasesTest.kt b/app/src/test/java/app/pachli/usecase/TimelineCasesTest.kt index c279b97b0..9bb5a9448 100644 --- a/app/src/test/java/app/pachli/usecase/TimelineCasesTest.kt +++ b/app/src/test/java/app/pachli/usecase/TimelineCasesTest.kt @@ -4,6 +4,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test import app.pachli.appstore.EventHub import app.pachli.appstore.PinEvent +import app.pachli.components.timeline.CachedTimelineRepository import app.pachli.entity.Status import app.pachli.network.MastodonApi import at.connyduck.calladapter.networkresult.NetworkResult @@ -25,6 +26,7 @@ class TimelineCasesTest { private lateinit var api: MastodonApi private lateinit var eventHub: EventHub + private lateinit var cachedTimelineRepository: CachedTimelineRepository private lateinit var timelineCases: TimelineCases private val statusId = "1234" @@ -33,7 +35,8 @@ class TimelineCasesTest { fun setup() { api = mock() eventHub = EventHub() - timelineCases = TimelineCases(api, eventHub) + cachedTimelineRepository = mock() + timelineCases = TimelineCases(api, eventHub, cachedTimelineRepository) } @Test diff --git a/app/src/test/java/app/pachli/util/StatusDisplayOptionsRepositoryTest.kt b/app/src/test/java/app/pachli/util/StatusDisplayOptionsRepositoryTest.kt index fa3da97ca..d66a72f01 100644 --- a/app/src/test/java/app/pachli/util/StatusDisplayOptionsRepositoryTest.kt +++ b/app/src/test/java/app/pachli/util/StatusDisplayOptionsRepositoryTest.kt @@ -25,6 +25,8 @@ import app.pachli.components.compose.HiltTestApplication_Application import app.pachli.components.timeline.MainCoroutineRule import app.pachli.db.AccountManager import app.pachli.entity.Account +import app.pachli.network.MastodonApi +import app.pachli.network.ServerCapabilitiesRepository import app.pachli.settings.AccountPreferenceDataStore import app.pachli.settings.PrefKeys import com.google.common.truth.Truth.assertThat @@ -63,6 +65,9 @@ class StatusDisplayOptionsRepositoryTest { @Inject lateinit var accountManager: AccountManager + @Inject + lateinit var mastodonApi: MastodonApi + @Inject lateinit var sharedPreferencesRepository: SharedPreferencesRepository @@ -102,8 +107,15 @@ class StatusDisplayOptionsRepositoryTest { TestScope(), ) + val serverCapabilitiesRepository = ServerCapabilitiesRepository( + mastodonApi, + accountManager, + TestScope(), + ) + statusDisplayOptionsRepository = StatusDisplayOptionsRepository( sharedPreferencesRepository, + serverCapabilitiesRepository, accountManager, accountPreferenceDataStore, TestScope(), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 407de3ebd..5c123b12a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,6 +38,7 @@ glide-animation-plugin = "2.23.0" gson = "2.10.1" hilt = "2.48.1" kotlin = "1.9.20" +kotlin-result = "1.1.8" image-cropper = "4.3.2" material = "1.9.0" material-drawer = "8.4.5" @@ -51,6 +52,7 @@ robolectric = "4.10.3" rxandroid3 = "3.0.2" rxjava3 = "3.1.7" rxkotlin3 = "3.0.1" +semver = "1.4.2" sparkbutton = "4.1.0" timber = "5.0.1" touchimageview = "3.6" @@ -129,6 +131,7 @@ gson = { module = "com.google.code.gson:gson", version.ref = "gson" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } +kotlin-result = { module = "com.michael-bull.kotlin-result:kotlin-result", version.ref = "kotlin-result" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-rx3 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-rx3", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } @@ -148,6 +151,7 @@ robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectr rxjava3-android = { module = "io.reactivex.rxjava3:rxandroid", version.ref = "rxandroid3" } rxjava3-core = { module = "io.reactivex.rxjava3:rxjava", version.ref = "rxjava3" } rxjava3-kotlin = { module = "io.reactivex.rxjava3:rxkotlin", version.ref = "rxkotlin3" } +semver = { module = "io.github.z4kn4fein:semver", version.ref = "semver" } sparkbutton = { module = "com.github.connyduck:sparkbutton", version.ref = "sparkbutton" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } touchimageview = { module = "com.github.MikeOrtiz:TouchImageView", version.ref = "touchimageview" }