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" }