Merge tag 'v18.0' into develop

This commit is contained in:
Mike Barnes 2022-06-14 22:53:13 +10:00
commit 474fca8f86
203 changed files with 6626 additions and 3019 deletions

View file

@ -7,9 +7,13 @@ apply from: "../instance-build.gradle"
def getGitSha = { def getGitSha = {
def stdout = new ByteArrayOutputStream() def stdout = new ByteArrayOutputStream()
exec { try {
commandLine 'git', 'rev-parse', '--short', 'HEAD' exec {
standardOutput = stdout commandLine 'git', 'rev-parse', '--short', 'HEAD'
standardOutput = stdout
}
} catch (Exception e) {
return "unknown"
} }
return stdout.toString().trim() return stdout.toString().trim()
} }
@ -21,7 +25,7 @@ android {
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 31 targetSdkVersion 31
versionCode 87 versionCode 87
versionName "17.0-CW1" versionName "18.0-CW1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@ -89,7 +93,7 @@ android {
} }
} }
ext.coroutinesVersion = "1.6.0" ext.coroutinesVersion = "1.6.1"
ext.lifecycleVersion = "2.4.1" ext.lifecycleVersion = "2.4.1"
ext.roomVersion = '2.4.2' ext.roomVersion = '2.4.2'
ext.retrofitVersion = '2.9.0' ext.retrofitVersion = '2.9.0'
@ -97,11 +101,11 @@ ext.okhttpVersion = '4.9.3'
ext.glideVersion = '4.13.1' ext.glideVersion = '4.13.1'
ext.daggerVersion = '2.41' ext.daggerVersion = '2.41'
ext.materialdrawerVersion = '8.4.5' ext.materialdrawerVersion = '8.4.5'
ext.emoji2_version = '1.1.0'
ext.filemojicompat_version = '3.2.1'
// if libraries are changed here, they should also be changed in LicenseActivity // if libraries are changed here, they should also be changed in LicenseActivity
dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion"
@ -115,8 +119,9 @@ dependencies {
implementation "androidx.cardview:cardview:1.0.0" implementation "androidx.cardview:cardview:1.0.0"
implementation "androidx.preference:preference-ktx:1.2.0" implementation "androidx.preference:preference-ktx:1.2.0"
implementation "androidx.sharetarget:sharetarget:1.2.0-rc01" implementation "androidx.sharetarget:sharetarget:1.2.0-rc01"
implementation "androidx.emoji:emoji:1.1.0" implementation "androidx.emoji2:emoji2:$emoji2_version"
implementation "androidx.emoji:emoji-appcompat:1.1.0" implementation "androidx.emoji2:emoji2-views:$emoji2_version"
implementation "androidx.emoji2:emoji2-views-helper:$emoji2_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
@ -127,7 +132,6 @@ dependencies {
implementation "androidx.work:work-runtime:2.7.1" implementation "androidx.work:work-runtime:2.7.1"
implementation "androidx.room:room-ktx:$roomVersion" implementation "androidx.room:room-ktx:$roomVersion"
implementation "androidx.room:room-paging:$roomVersion" implementation "androidx.room:room-paging:$roomVersion"
implementation "androidx.room:room-rxjava3:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion" kapt "androidx.room:room-compiler:$roomVersion"
implementation 'androidx.core:core-splashscreen:1.0.0-beta02' implementation 'androidx.core:core-splashscreen:1.0.0-beta02'
@ -138,6 +142,7 @@ dependencies {
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"
implementation "com.squareup.retrofit2:adapter-rxjava3:$retrofitVersion" implementation "com.squareup.retrofit2:adapter-rxjava3:$retrofitVersion"
implementation "at.connyduck:kotlin-result-calladapter:1.0.1"
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion" implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion"
@ -173,12 +178,14 @@ dependencies {
implementation "com.github.CanHub:Android-Image-Cropper:4.1.0" implementation "com.github.CanHub:Android-Image-Cropper:4.1.0"
implementation "de.c1710:filemojicompat:1.0.18" implementation "de.c1710:filemojicompat-ui:$filemojicompat_version"
implementation "de.c1710:filemojicompat:$filemojicompat_version"
implementation "de.c1710:filemojicompat-defaults:$filemojicompat_version"
testImplementation "androidx.test.ext:junit:1.1.3" testImplementation "androidx.test.ext:junit:1.1.3"
testImplementation "org.robolectric:robolectric:4.4" testImplementation "org.robolectric:robolectric:4.4"
testImplementation "org.mockito:mockito-inline:3.6.28" testImplementation "org.mockito:mockito-inline:4.4.0"
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0" testImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0"
androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0" androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0"
androidTestImplementation "androidx.room:room-testing:$roomVersion" androidTestImplementation "androidx.room:room-testing:$roomVersion"

View file

@ -0,0 +1,815 @@
{
"formatVersion": 1,
"database": {
"version": 32,
"identityHash": "c92343960c9d46d9cfd49f1873cce47d",
"entities": [
{
"tableName": "DraftEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentWarning",
"columnName": "contentWarning",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "failedToSend",
"columnName": "failedToSend",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "AccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "domain",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profilePictureUrl",
"columnName": "profilePictureUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsMentioned",
"columnName": "notificationsMentioned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowed",
"columnName": "notificationsFollowed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowRequested",
"columnName": "notificationsFollowRequested",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsReblogged",
"columnName": "notificationsReblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFavorited",
"columnName": "notificationsFavorited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsPolls",
"columnName": "notificationsPolls",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsSubscriptions",
"columnName": "notificationsSubscriptions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsSignUps",
"columnName": "notificationsSignUps",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationSound",
"columnName": "notificationSound",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationVibration",
"columnName": "notificationVibration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationLight",
"columnName": "notificationLight",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultPostPrivacy",
"columnName": "defaultPostPrivacy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultMediaSensitivity",
"columnName": "defaultMediaSensitivity",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysShowSensitiveMedia",
"columnName": "alwaysShowSensitiveMedia",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysOpenSpoiler",
"columnName": "alwaysOpenSpoiler",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mediaPreviewEnabled",
"columnName": "mediaPreviewEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastNotificationId",
"columnName": "lastNotificationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activeNotifications",
"columnName": "activeNotifications",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tabPreferences",
"columnName": "tabPreferences",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsFilter",
"columnName": "notificationsFilter",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_AccountEntity_domain_accountId",
"unique": true,
"columnNames": [
"domain",
"accountId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)"
}
],
"foreignKeys": []
},
{
"tableName": "InstanceEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))",
"fields": [
{
"fieldPath": "instance",
"columnName": "instance",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojiList",
"columnName": "emojiList",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "maximumTootCharacters",
"columnName": "maximumTootCharacters",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptions",
"columnName": "maxPollOptions",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptionLength",
"columnName": "maxPollOptionLength",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "minPollDuration",
"columnName": "minPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollDuration",
"columnName": "maxPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "charactersReservedPerUrl",
"columnName": "charactersReservedPerUrl",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"instance"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TimelineStatusEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authorServerId",
"columnName": "authorServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToAccountId",
"columnName": "inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogsCount",
"columnName": "reblogsCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favouritesCount",
"columnName": "favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "spoilerText",
"columnName": "spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogServerId",
"columnName": "reblogServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogAccountId",
"columnName": "reblogAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "expanded",
"columnName": "expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentCollapsed",
"columnName": "contentCollapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentShowing",
"columnName": "contentShowing",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
}
],
"foreignKeys": [
{
"table": "TimelineAccountEntity",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"authorServerId",
"timelineUserId"
],
"referencedColumns": [
"serverId",
"timelineUserId"
]
}
]
},
{
"tableName": "TimelineAccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUsername",
"columnName": "localUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bot",
"columnName": "bot",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accounts",
"columnName": "accounts",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.id",
"columnName": "s_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.url",
"columnName": "s_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToId",
"columnName": "s_inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToAccountId",
"columnName": "s_inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.account",
"columnName": "s_account",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.content",
"columnName": "s_content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.createdAt",
"columnName": "s_createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.emojis",
"columnName": "s_emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.favouritesCount",
"columnName": "s_favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.favourited",
"columnName": "s_favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.bookmarked",
"columnName": "s_bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.sensitive",
"columnName": "s_sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.spoilerText",
"columnName": "s_spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.attachments",
"columnName": "s_attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.mentions",
"columnName": "s_mentions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.tags",
"columnName": "s_tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsible",
"columnName": "s_collapsible",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsed",
"columnName": "s_collapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.muted",
"columnName": "s_muted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.poll",
"columnName": "s_poll",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id",
"accountId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c92343960c9d46d9cfd49f1873cce47d')"
]
}
}

View file

@ -0,0 +1,809 @@
{
"formatVersion": 1,
"database": {
"version": 33,
"identityHash": "920a0e0c9a600bd236f6bf959b469c18",
"entities": [
{
"tableName": "DraftEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentWarning",
"columnName": "contentWarning",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "failedToSend",
"columnName": "failedToSend",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "AccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "domain",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profilePictureUrl",
"columnName": "profilePictureUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsMentioned",
"columnName": "notificationsMentioned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowed",
"columnName": "notificationsFollowed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowRequested",
"columnName": "notificationsFollowRequested",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsReblogged",
"columnName": "notificationsReblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFavorited",
"columnName": "notificationsFavorited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsPolls",
"columnName": "notificationsPolls",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsSubscriptions",
"columnName": "notificationsSubscriptions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsSignUps",
"columnName": "notificationsSignUps",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationSound",
"columnName": "notificationSound",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationVibration",
"columnName": "notificationVibration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationLight",
"columnName": "notificationLight",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultPostPrivacy",
"columnName": "defaultPostPrivacy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultMediaSensitivity",
"columnName": "defaultMediaSensitivity",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysShowSensitiveMedia",
"columnName": "alwaysShowSensitiveMedia",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysOpenSpoiler",
"columnName": "alwaysOpenSpoiler",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mediaPreviewEnabled",
"columnName": "mediaPreviewEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastNotificationId",
"columnName": "lastNotificationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activeNotifications",
"columnName": "activeNotifications",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tabPreferences",
"columnName": "tabPreferences",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsFilter",
"columnName": "notificationsFilter",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_AccountEntity_domain_accountId",
"unique": true,
"columnNames": [
"domain",
"accountId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)"
}
],
"foreignKeys": []
},
{
"tableName": "InstanceEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))",
"fields": [
{
"fieldPath": "instance",
"columnName": "instance",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojiList",
"columnName": "emojiList",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "maximumTootCharacters",
"columnName": "maximumTootCharacters",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptions",
"columnName": "maxPollOptions",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptionLength",
"columnName": "maxPollOptionLength",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "minPollDuration",
"columnName": "minPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollDuration",
"columnName": "maxPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "charactersReservedPerUrl",
"columnName": "charactersReservedPerUrl",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"instance"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TimelineStatusEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authorServerId",
"columnName": "authorServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToAccountId",
"columnName": "inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogsCount",
"columnName": "reblogsCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favouritesCount",
"columnName": "favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "spoilerText",
"columnName": "spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogServerId",
"columnName": "reblogServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogAccountId",
"columnName": "reblogAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "expanded",
"columnName": "expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentCollapsed",
"columnName": "contentCollapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentShowing",
"columnName": "contentShowing",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
}
],
"foreignKeys": [
{
"table": "TimelineAccountEntity",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"authorServerId",
"timelineUserId"
],
"referencedColumns": [
"serverId",
"timelineUserId"
]
}
]
},
{
"tableName": "TimelineAccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUsername",
"columnName": "localUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bot",
"columnName": "bot",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accounts",
"columnName": "accounts",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.id",
"columnName": "s_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.url",
"columnName": "s_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToId",
"columnName": "s_inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToAccountId",
"columnName": "s_inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.account",
"columnName": "s_account",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.content",
"columnName": "s_content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.createdAt",
"columnName": "s_createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.emojis",
"columnName": "s_emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.favouritesCount",
"columnName": "s_favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.favourited",
"columnName": "s_favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.bookmarked",
"columnName": "s_bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.sensitive",
"columnName": "s_sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.spoilerText",
"columnName": "s_spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.attachments",
"columnName": "s_attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.mentions",
"columnName": "s_mentions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.tags",
"columnName": "s_tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsed",
"columnName": "s_collapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.muted",
"columnName": "s_muted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.poll",
"columnName": "s_poll",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id",
"accountId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '920a0e0c9a600bd236f6bf959b469c18')"
]
}
}

View file

@ -0,0 +1,815 @@
{
"formatVersion": 1,
"database": {
"version": 34,
"identityHash": "7f766d68ab5d72a7988cd81c183e9a9d",
"entities": [
{
"tableName": "DraftEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentWarning",
"columnName": "contentWarning",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "failedToSend",
"columnName": "failedToSend",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "AccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "domain",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profilePictureUrl",
"columnName": "profilePictureUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsMentioned",
"columnName": "notificationsMentioned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowed",
"columnName": "notificationsFollowed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowRequested",
"columnName": "notificationsFollowRequested",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsReblogged",
"columnName": "notificationsReblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFavorited",
"columnName": "notificationsFavorited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsPolls",
"columnName": "notificationsPolls",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsSubscriptions",
"columnName": "notificationsSubscriptions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsSignUps",
"columnName": "notificationsSignUps",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsUpdates",
"columnName": "notificationsUpdates",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationSound",
"columnName": "notificationSound",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationVibration",
"columnName": "notificationVibration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationLight",
"columnName": "notificationLight",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultPostPrivacy",
"columnName": "defaultPostPrivacy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultMediaSensitivity",
"columnName": "defaultMediaSensitivity",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysShowSensitiveMedia",
"columnName": "alwaysShowSensitiveMedia",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysOpenSpoiler",
"columnName": "alwaysOpenSpoiler",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mediaPreviewEnabled",
"columnName": "mediaPreviewEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastNotificationId",
"columnName": "lastNotificationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activeNotifications",
"columnName": "activeNotifications",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tabPreferences",
"columnName": "tabPreferences",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsFilter",
"columnName": "notificationsFilter",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_AccountEntity_domain_accountId",
"unique": true,
"columnNames": [
"domain",
"accountId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)"
}
],
"foreignKeys": []
},
{
"tableName": "InstanceEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))",
"fields": [
{
"fieldPath": "instance",
"columnName": "instance",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojiList",
"columnName": "emojiList",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "maximumTootCharacters",
"columnName": "maximumTootCharacters",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptions",
"columnName": "maxPollOptions",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptionLength",
"columnName": "maxPollOptionLength",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "minPollDuration",
"columnName": "minPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollDuration",
"columnName": "maxPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "charactersReservedPerUrl",
"columnName": "charactersReservedPerUrl",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"instance"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TimelineStatusEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authorServerId",
"columnName": "authorServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToAccountId",
"columnName": "inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogsCount",
"columnName": "reblogsCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favouritesCount",
"columnName": "favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "spoilerText",
"columnName": "spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogServerId",
"columnName": "reblogServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogAccountId",
"columnName": "reblogAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "expanded",
"columnName": "expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentCollapsed",
"columnName": "contentCollapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentShowing",
"columnName": "contentShowing",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
}
],
"foreignKeys": [
{
"table": "TimelineAccountEntity",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"authorServerId",
"timelineUserId"
],
"referencedColumns": [
"serverId",
"timelineUserId"
]
}
]
},
{
"tableName": "TimelineAccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUsername",
"columnName": "localUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bot",
"columnName": "bot",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accounts",
"columnName": "accounts",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.id",
"columnName": "s_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.url",
"columnName": "s_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToId",
"columnName": "s_inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToAccountId",
"columnName": "s_inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.account",
"columnName": "s_account",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.content",
"columnName": "s_content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.createdAt",
"columnName": "s_createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.emojis",
"columnName": "s_emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.favouritesCount",
"columnName": "s_favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.favourited",
"columnName": "s_favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.bookmarked",
"columnName": "s_bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.sensitive",
"columnName": "s_sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.spoilerText",
"columnName": "s_spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.attachments",
"columnName": "s_attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.mentions",
"columnName": "s_mentions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.tags",
"columnName": "s_tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsed",
"columnName": "s_collapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.muted",
"columnName": "s_muted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.poll",
"columnName": "s_poll",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id",
"accountId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7f766d68ab5d72a7988cd81c183e9a9d')"
]
}
}

View file

@ -0,0 +1,821 @@
{
"formatVersion": 1,
"database": {
"version": 35,
"identityHash": "9e6c0bb60538683a16c30fa3e1cc24f2",
"entities": [
{
"tableName": "DraftEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentWarning",
"columnName": "contentWarning",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "failedToSend",
"columnName": "failedToSend",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "AccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "domain",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profilePictureUrl",
"columnName": "profilePictureUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsMentioned",
"columnName": "notificationsMentioned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowed",
"columnName": "notificationsFollowed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowRequested",
"columnName": "notificationsFollowRequested",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsReblogged",
"columnName": "notificationsReblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFavorited",
"columnName": "notificationsFavorited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsPolls",
"columnName": "notificationsPolls",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsSubscriptions",
"columnName": "notificationsSubscriptions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsSignUps",
"columnName": "notificationsSignUps",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsUpdates",
"columnName": "notificationsUpdates",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationSound",
"columnName": "notificationSound",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationVibration",
"columnName": "notificationVibration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationLight",
"columnName": "notificationLight",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultPostPrivacy",
"columnName": "defaultPostPrivacy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultMediaSensitivity",
"columnName": "defaultMediaSensitivity",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysShowSensitiveMedia",
"columnName": "alwaysShowSensitiveMedia",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysOpenSpoiler",
"columnName": "alwaysOpenSpoiler",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mediaPreviewEnabled",
"columnName": "mediaPreviewEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastNotificationId",
"columnName": "lastNotificationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activeNotifications",
"columnName": "activeNotifications",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tabPreferences",
"columnName": "tabPreferences",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsFilter",
"columnName": "notificationsFilter",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_AccountEntity_domain_accountId",
"unique": true,
"columnNames": [
"domain",
"accountId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)"
}
],
"foreignKeys": []
},
{
"tableName": "InstanceEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))",
"fields": [
{
"fieldPath": "instance",
"columnName": "instance",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojiList",
"columnName": "emojiList",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "maximumTootCharacters",
"columnName": "maximumTootCharacters",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptions",
"columnName": "maxPollOptions",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptionLength",
"columnName": "maxPollOptionLength",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "minPollDuration",
"columnName": "minPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollDuration",
"columnName": "maxPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "charactersReservedPerUrl",
"columnName": "charactersReservedPerUrl",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"instance"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TimelineStatusEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authorServerId",
"columnName": "authorServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToAccountId",
"columnName": "inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogsCount",
"columnName": "reblogsCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favouritesCount",
"columnName": "favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "spoilerText",
"columnName": "spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogServerId",
"columnName": "reblogServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogAccountId",
"columnName": "reblogAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "expanded",
"columnName": "expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentCollapsed",
"columnName": "contentCollapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentShowing",
"columnName": "contentShowing",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "card",
"columnName": "card",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
}
],
"foreignKeys": [
{
"table": "TimelineAccountEntity",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"authorServerId",
"timelineUserId"
],
"referencedColumns": [
"serverId",
"timelineUserId"
]
}
]
},
{
"tableName": "TimelineAccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUsername",
"columnName": "localUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bot",
"columnName": "bot",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accounts",
"columnName": "accounts",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.id",
"columnName": "s_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.url",
"columnName": "s_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToId",
"columnName": "s_inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToAccountId",
"columnName": "s_inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.account",
"columnName": "s_account",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.content",
"columnName": "s_content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.createdAt",
"columnName": "s_createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.emojis",
"columnName": "s_emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.favouritesCount",
"columnName": "s_favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.favourited",
"columnName": "s_favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.bookmarked",
"columnName": "s_bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.sensitive",
"columnName": "s_sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.spoilerText",
"columnName": "s_spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.attachments",
"columnName": "s_attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.mentions",
"columnName": "s_mentions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.tags",
"columnName": "s_tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsed",
"columnName": "s_collapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.muted",
"columnName": "s_muted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.poll",
"columnName": "s_poll",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id",
"accountId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9e6c0bb60538683a16c30fa3e1cc24f2')"
]
}
}

View file

@ -35,8 +35,7 @@ import androidx.appcompat.app.AlertDialog
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.emoji.text.EmojiCompat import androidx.core.view.GravityCompat
import androidx.emoji.text.EmojiCompat.InitCallback
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
@ -114,6 +113,7 @@ import com.mikepenz.materialdrawer.util.updateBadge
import com.mikepenz.materialdrawer.widget.AccountHeaderView import com.mikepenz.materialdrawer.widget.AccountHeaderView
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -150,13 +150,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private var accountLocked: Boolean = false private var accountLocked: Boolean = false
private val emojiInitCallback = object : InitCallback() { // We need to know if the emoji pack has been changed
override fun onInitialized() { private var selectedEmojiPack: String? = null
if (!isDestroyed) {
updateProfiles()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -271,11 +266,31 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
// Flush old media that was cached for sharing // Flush old media that was cached for sharing
deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky")) deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky"))
} }
selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "")
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
NotificationHelper.clearNotificationsForActiveAccount(this, accountManager) NotificationHelper.clearNotificationsForActiveAccount(this, accountManager)
val currentEmojiPack = preferences.getString(EMOJI_PREFERENCE, "")
if (currentEmojiPack != selectedEmojiPack) {
Log.d(
TAG,
"onResume: EmojiPack has been changed from %s to %s"
.format(selectedEmojiPack, currentEmojiPack)
)
selectedEmojiPack = currentEmojiPack
recreate()
}
}
override fun onStart() {
super.onStart()
// For some reason the navigation drawer is opened when the activity is recreated
if (binding.mainDrawerLayout.isOpen) {
binding.mainDrawerLayout.closeDrawer(GravityCompat.START, false)
}
} }
override fun onBackPressed() { override fun onBackPressed() {
@ -333,11 +348,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
} }
override fun onDestroy() {
super.onDestroy()
EmojiCompat.get().unregisterInitCallback(emojiInitCallback)
}
private fun forwardShare(intent: Intent) { private fun forwardShare(intent: Intent) {
val composeIntent = Intent(this, ComposeActivity::class.java) val composeIntent = Intent(this, ComposeActivity::class.java)
composeIntent.action = intent.action composeIntent.action = intent.action
@ -530,7 +540,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
) )
} }
EmojiCompat.get().registerInitCallback(emojiInitCallback)
} }
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
@ -612,6 +621,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
binding.mainToolbar.setOnClickListener { binding.mainToolbar.setOnClickListener {
(adapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect() (adapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect()
} }
updateProfiles()
} }
private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean { private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean {
@ -682,18 +693,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
} }
private fun fetchUserInfo() { private fun fetchUserInfo() = lifecycleScope.launch {
mastodonApi.accountVerifyCredentials() mastodonApi.accountVerifyCredentials().fold(
.observeOn(AndroidSchedulers.mainThread()) { userInfo ->
.autoDispose(this, Lifecycle.Event.ON_DESTROY) onFetchUserInfoSuccess(userInfo)
.subscribe( },
{ userInfo -> { throwable ->
onFetchUserInfoSuccess(userInfo) Log.e(TAG, "Failed to fetch user info. " + throwable.message)
}, }
{ throwable -> )
Log.e(TAG, "Failed to fetch user info. " + throwable.message)
}
)
} }
private fun onFetchUserInfoSuccess(me: Account) { private fun onFetchUserInfoSuccess(me: Account) {
@ -782,18 +790,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
private fun fetchAnnouncements() { private fun fetchAnnouncements() {
mastodonApi.listAnnouncements(false) lifecycleScope.launch {
.observeOn(AndroidSchedulers.mainThread()) mastodonApi.listAnnouncements(false)
.autoDispose(this, Lifecycle.Event.ON_DESTROY) .fold(
.subscribe( { announcements ->
{ announcements -> unreadAnnouncementsCount = announcements.count { !it.read }
unreadAnnouncementsCount = announcements.count { !it.read } updateAnnouncementsBadge()
updateAnnouncementsBadge() },
}, { throwable ->
{ Log.w(TAG, "Failed to fetch announcements.", throwable)
Log.w(TAG, "Failed to fetch announcements.", it) }
} )
) }
} }
private fun updateAnnouncementsBadge() { private fun updateAnnouncementsBadge() {
@ -803,11 +811,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private fun updateProfiles() { private fun updateProfiles() {
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
val profiles: MutableList<IProfile> = accountManager.getAllAccountsOrderedByActive().map { acc -> val profiles: MutableList<IProfile> = accountManager.getAllAccountsOrderedByActive().map { acc ->
val emojifiedName = EmojiCompat.get().process(acc.displayName.emojify(acc.emojis, header, animateEmojis))
ProfileDrawerItem().apply { ProfileDrawerItem().apply {
isSelected = acc.isActive isSelected = acc.isActive
nameText = emojifiedName nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis)
iconUrl = acc.profilePictureUrl iconUrl = acc.profilePictureUrl
isNameShown = true isNameShown = true
identifier = acc.id identifier = acc.id

View file

@ -19,18 +19,18 @@ import android.app.Application
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.util.Log import android.util.Log
import androidx.emoji.text.EmojiCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.work.WorkManager import androidx.work.WorkManager
import autodispose2.AutoDisposePlugins import autodispose2.AutoDisposePlugins
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory
import com.keylesspalace.tusky.di.AppInjector import com.keylesspalace.tusky.di.AppInjector
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.EmojiCompatFont
import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.LocaleManager
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import de.c1710.filemojicompat_defaults.DefaultEmojiPackList
import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper
import de.c1710.filemojicompat_ui.helpers.EmojiPreference
import io.reactivex.rxjava3.plugins.RxJavaPlugins import io.reactivex.rxjava3.plugins.RxJavaPlugins
import org.conscrypt.Conscrypt import org.conscrypt.Conscrypt
import java.security.Security import java.security.Security
@ -65,12 +65,10 @@ class TuskyApplication : Application(), HasAndroidInjector {
val preferences = PreferenceManager.getDefaultSharedPreferences(this) val preferences = PreferenceManager.getDefaultSharedPreferences(this)
// init the custom emoji fonts // In this case, we want to have the emoji preferences merged with the other ones
val emojiSelection = preferences.getInt(PrefKeys.EMOJI, 0) // Copied from PreferenceManager.getDefaultSharedPreferenceName
val emojiConfig = EmojiCompatFont.byId(emojiSelection) EmojiPreference.sharedPreferenceName = packageName + "_preferences"
.getConfig(this) EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false)
.setReplaceAll(true)
EmojiCompat.init(emojiConfig)
// init night mode // init night mode
val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT) val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT)

View file

@ -283,7 +283,6 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
} }
return@fromCallable false return@fromCallable false
} }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.doOnDispose { .doOnDispose {

View file

@ -45,8 +45,7 @@ public class AccountViewHolder extends RecyclerView.ViewHolder {
ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, animateAvatar); ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, animateAvatar);
if (showBotOverlay && account.getBot()) { if (showBotOverlay && account.getBot()) {
avatarInset.setVisibility(View.VISIBLE); avatarInset.setVisibility(View.VISIBLE);
avatarInset.setImageResource(R.drawable.ic_bot_24dp); avatarInset.setImageResource(R.drawable.bot_badge);
avatarInset.setBackgroundColor(0x50ffffff);
} else { } else {
avatarInset.setVisibility(View.GONE); avatarInset.setVisibility(View.GONE);
} }

View file

@ -32,6 +32,8 @@ import android.widget.Button;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.ColorRes;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
@ -47,6 +49,7 @@ import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.interfaces.AccountActionListener; import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper;
@ -58,10 +61,8 @@ import com.keylesspalace.tusky.util.TimestampUtils;
import com.keylesspalace.tusky.viewdata.NotificationViewData; import com.keylesspalace.tusky.viewdata.NotificationViewData;
import com.keylesspalace.tusky.viewdata.StatusViewData; import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.text.SimpleDateFormat;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Locale;
import at.connyduck.sparkbutton.helpers.Utils; import at.connyduck.sparkbutton.helpers.Utils;
@ -90,6 +91,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
private NotificationActionListener notificationActionListener; private NotificationActionListener notificationActionListener;
private AccountActionListener accountActionListener; private AccountActionListener accountActionListener;
private AdapterDataSource<NotificationViewData> dataSource; private AdapterDataSource<NotificationViewData> dataSource;
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
public NotificationsAdapter(String accountId, public NotificationsAdapter(String accountId,
AdapterDataSource<NotificationViewData> dataSource, AdapterDataSource<NotificationViewData> dataSource,
@ -119,7 +121,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
case VIEW_TYPE_STATUS_NOTIFICATION: { case VIEW_TYPE_STATUS_NOTIFICATION: {
View view = inflater View view = inflater
.inflate(R.layout.item_status_notification, parent, false); .inflate(R.layout.item_status_notification, parent, false);
return new StatusNotificationViewHolder(view, statusDisplayOptions); return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter);
} }
case VIEW_TYPE_FOLLOW: { case VIEW_TYPE_FOLLOW: {
View view = inflater View view = inflater
@ -178,8 +180,16 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
case VIEW_TYPE_STATUS: { case VIEW_TYPE_STATUS: {
StatusViewHolder holder = (StatusViewHolder) viewHolder; StatusViewHolder holder = (StatusViewHolder) viewHolder;
StatusViewData.Concrete status = concreteNotificaton.getStatusViewData(); StatusViewData.Concrete status = concreteNotificaton.getStatusViewData();
holder.setupWithStatus(status, if (status == null) {
statusListener, statusDisplayOptions, payloadForHolder); /* in some very rare cases servers sends null status even though they should not,
* we have to handle it somehow */
holder.showStatusContent(false);
} else {
if (payloads == null) {
holder.showStatusContent(true);
}
holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder);
}
if (concreteNotificaton.getType() == Notification.Type.POLL) { if (concreteNotificaton.getType() == Notification.Type.POLL) {
holder.setPollInfo(accountId.equals(concreteNotificaton.getAccount().getId())); holder.setPollInfo(accountId.equals(concreteNotificaton.getAccount().getId()));
} else { } else {
@ -192,6 +202,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
StatusViewData.Concrete statusViewData = concreteNotificaton.getStatusViewData(); StatusViewData.Concrete statusViewData = concreteNotificaton.getStatusViewData();
if (payloadForHolder == null) { if (payloadForHolder == null) {
if (statusViewData == null) { if (statusViewData == null) {
/* in some very rare cases servers sends null status even though they should not,
* we have to handle it somehow */
holder.showNotificationContent(false); holder.showNotificationContent(false);
} else { } else {
holder.showNotificationContent(true); holder.showNotificationContent(true);
@ -201,7 +213,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
holder.setUsername(status.getAccount().getUsername()); holder.setUsername(status.getAccount().getUsername());
holder.setCreatedAt(status.getCreatedAt()); holder.setCreatedAt(status.getCreatedAt());
if (concreteNotificaton.getType() == Notification.Type.STATUS) { if (concreteNotificaton.getType() == Notification.Type.STATUS ||
concreteNotificaton.getType() == Notification.Type.UPDATE) {
holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot()); holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot());
} else { } else {
holder.setAvatars(status.getAccount().getAvatar(), holder.setAvatars(status.getAccount().getAvatar(),
@ -226,7 +239,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
case VIEW_TYPE_FOLLOW: { case VIEW_TYPE_FOLLOW: {
if (payloadForHolder == null) { if (payloadForHolder == null) {
FollowViewHolder holder = (FollowViewHolder) viewHolder; FollowViewHolder holder = (FollowViewHolder) viewHolder;
holder.setMessage(concreteNotificaton.getAccount()); holder.setMessage(concreteNotificaton.getAccount(), concreteNotificaton.getType() == Notification.Type.SIGN_UP);
holder.setupButtons(notificationActionListener, concreteNotificaton.getAccount().getId()); holder.setupButtons(notificationActionListener, concreteNotificaton.getAccount().getId());
} }
break; break;
@ -280,10 +293,12 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
} }
case STATUS: case STATUS:
case FAVOURITE: case FAVOURITE:
case REBLOG: { case REBLOG:
case UPDATE: {
return VIEW_TYPE_STATUS_NOTIFICATION; return VIEW_TYPE_STATUS_NOTIFICATION;
} }
case FOLLOW: { case FOLLOW:
case SIGN_UP: {
return VIEW_TYPE_FOLLOW; return VIEW_TYPE_FOLLOW;
} }
case FOLLOW_REQUEST: { case FOLLOW_REQUEST: {
@ -335,10 +350,10 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
this.statusDisplayOptions = statusDisplayOptions; this.statusDisplayOptions = statusDisplayOptions;
} }
void setMessage(TimelineAccount account) { void setMessage(TimelineAccount account, Boolean isSignUp) {
Context context = message.getContext(); Context context = message.getContext();
String format = context.getString(R.string.notification_follow_format); String format = context.getString(isSignUp ? R.string.notification_sign_up_format : R.string.notification_follow_format);
String wrappedDisplayName = StringUtils.unicodeWrap(account.getName()); String wrappedDisplayName = StringUtils.unicodeWrap(account.getName());
String wholeMessage = String.format(format, wrappedDisplayName); String wholeMessage = String.format(format, wrappedDisplayName);
CharSequence emojifiedMessage = CustomEmojiHelper.emojify( CharSequence emojifiedMessage = CustomEmojiHelper.emojify(
@ -382,19 +397,22 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
private final Button contentWarningButton; private final Button contentWarningButton;
private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder
private StatusDisplayOptions statusDisplayOptions; private StatusDisplayOptions statusDisplayOptions;
private final AbsoluteTimeFormatter absoluteTimeFormatter;
private String accountId; private String accountId;
private String notificationId; private String notificationId;
private NotificationActionListener notificationActionListener; private NotificationActionListener notificationActionListener;
private StatusViewData.Concrete statusViewData; private StatusViewData.Concrete statusViewData;
private SimpleDateFormat shortSdf;
private SimpleDateFormat longSdf;
private int avatarRadius48dp; private int avatarRadius48dp;
private int avatarRadius36dp; private int avatarRadius36dp;
private int avatarRadius24dp; private int avatarRadius24dp;
StatusNotificationViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) { StatusNotificationViewHolder(
View itemView,
StatusDisplayOptions statusDisplayOptions,
AbsoluteTimeFormatter absoluteTimeFormatter
) {
super(itemView); super(itemView);
message = itemView.findViewById(R.id.notification_top_text); message = itemView.findViewById(R.id.notification_top_text);
statusNameBar = itemView.findViewById(R.id.status_name_bar); statusNameBar = itemView.findViewById(R.id.status_name_bar);
@ -408,6 +426,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button); contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button);
contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content); contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content);
this.statusDisplayOptions = statusDisplayOptions; this.statusDisplayOptions = statusDisplayOptions;
this.absoluteTimeFormatter = absoluteTimeFormatter;
int darkerFilter = Color.rgb(123, 123, 123); int darkerFilter = Color.rgb(123, 123, 123);
statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY);
@ -416,8 +435,6 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
itemView.setOnClickListener(this); itemView.setOnClickListener(this);
message.setOnClickListener(this); message.setOnClickListener(this);
statusContent.setOnClickListener(this); statusContent.setOnClickListener(this);
shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault());
longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault());
this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp);
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
@ -447,17 +464,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
protected void setCreatedAt(@Nullable Date createdAt) { protected void setCreatedAt(@Nullable Date createdAt) {
if (statusDisplayOptions.useAbsoluteTime()) { if (statusDisplayOptions.useAbsoluteTime()) {
String time; timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true));
if (createdAt != null) {
if (System.currentTimeMillis() - createdAt.getTime() > 86400000L) {
time = longSdf.format(createdAt);
} else {
time = shortSdf.format(createdAt);
}
} else {
time = "??:??:??";
}
timestampInfo.setText(time);
} else { } else {
// This is the visible timestampInfo. // This is the visible timestampInfo.
String readout; String readout;
@ -481,6 +488,14 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
} }
} }
Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) {
Drawable icon = ContextCompat.getDrawable(context, drawable);
if (icon != null) {
icon.setColorFilter(ContextCompat.getColor(context, color), PorterDuff.Mode.SRC_ATOP);
}
return icon;
}
void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) { void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) {
this.statusViewData = notificationViewData.getStatusViewData(); this.statusViewData = notificationViewData.getStatusViewData();
@ -493,41 +508,36 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
switch (type) { switch (type) {
default: default:
case FAVOURITE: { case FAVOURITE: {
icon = ContextCompat.getDrawable(context, R.drawable.ic_star_24dp); icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange);
if (icon != null) {
icon.setColorFilter(ContextCompat.getColor(context,
R.color.tusky_orange), PorterDuff.Mode.SRC_ATOP);
}
format = context.getString(R.string.notification_favourite_format); format = context.getString(R.string.notification_favourite_format);
break; break;
} }
case REBLOG: { case REBLOG: {
icon = ContextCompat.getDrawable(context, R.drawable.ic_repeat_24dp); icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.chinwag_green);
if (icon != null) {
icon.setColorFilter(ContextCompat.getColor(context,
R.color.chinwag_green), PorterDuff.Mode.SRC_ATOP);
}
format = context.getString(R.string.notification_reblog_format); format = context.getString(R.string.notification_reblog_format);
break; break;
} }
case STATUS: { case STATUS: {
icon = ContextCompat.getDrawable(context, R.drawable.ic_home_24dp); icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.chinwag_green);
if (icon != null) {
icon.setColorFilter(ContextCompat.getColor(context,
R.color.chinwag_green), PorterDuff.Mode.SRC_ATOP);
}
format = context.getString(R.string.notification_subscription_format); format = context.getString(R.string.notification_subscription_format);
break; break;
} }
case UPDATE: {
icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.chinwag_green);
format = context.getString(R.string.notification_update_format);
break;
}
} }
message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null); message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null);
String wholeMessage = String.format(format, displayName); String wholeMessage = String.format(format, displayName);
final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage); final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage);
str.setSpan(new StyleSpan(Typeface.BOLD), 0, displayName.length(), int displayNameIndex = format.indexOf("%s");
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); str.setSpan(
new StyleSpan(Typeface.BOLD),
displayNameIndex,
displayNameIndex + displayName.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
);
CharSequence emojifiedText = CustomEmojiHelper.emojify( CharSequence emojifiedText = CustomEmojiHelper.emojify(
str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis() str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis()
); );
@ -570,9 +580,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
if (statusDisplayOptions.showBotOverlay() && isBot) { if (statusDisplayOptions.showBotOverlay() && isBot) {
notificationAvatar.setVisibility(View.VISIBLE); notificationAvatar.setVisibility(View.VISIBLE);
notificationAvatar.setBackgroundColor(0x50ffffff);
Glide.with(notificationAvatar) Glide.with(notificationAvatar)
.load(R.drawable.ic_bot_24dp) .load(ContextCompat.getDrawable(notificationAvatar.getContext(), R.drawable.bot_badge))
.into(notificationAvatar); .into(notificationAvatar);
} else { } else {

View file

@ -19,7 +19,6 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.emoji.text.EmojiCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemPollBinding import com.keylesspalace.tusky.databinding.ItemPollBinding
@ -87,9 +86,8 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
when (mode) { when (mode) {
RESULT -> { RESULT -> {
val percent = calculatePercent(option.votesCount, votersCount, voteCount) val percent = calculatePercent(option.votesCount, votersCount, voteCount)
val emojifiedPollOptionText = buildDescription(option.title, percent, option.voted, resultTextView.context) resultTextView.text = buildDescription(option.title, percent, option.voted, resultTextView.context)
.emojify(emojis, resultTextView, animateEmojis) .emojify(emojis, resultTextView, animateEmojis)
resultTextView.text = EmojiCompat.get().process(emojifiedPollOptionText)
val level = percent * 100 val level = percent * 100
val optionColor = if (option.voted) { val optionColor = if (option.voted) {
@ -103,8 +101,7 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
resultTextView.setOnClickListener(resultClickListener) resultTextView.setOnClickListener(resultClickListener)
} }
SINGLE -> { SINGLE -> {
val emojifiedPollOptionText = option.title.emojify(emojis, radioButton, animateEmojis) radioButton.text = option.title.emojify(emojis, radioButton, animateEmojis)
radioButton.text = EmojiCompat.get().process(emojifiedPollOptionText)
radioButton.isChecked = option.selected radioButton.isChecked = option.selected
radioButton.setOnClickListener { radioButton.setOnClickListener {
pollOptions.forEachIndexed { index, pollOption -> pollOptions.forEachIndexed { index, pollOption ->
@ -114,8 +111,7 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
} }
} }
MULTIPLE -> { MULTIPLE -> {
val emojifiedPollOptionText = option.title.emojify(emojis, checkBox, animateEmojis) checkBox.text = option.title.emojify(emojis, checkBox, animateEmojis)
checkBox.text = EmojiCompat.get().process(emojifiedPollOptionText)
checkBox.isChecked = option.selected checkBox.isChecked = option.selected
checkBox.setOnCheckedChangeListener { _, isChecked -> checkBox.setOnCheckedChangeListener { _, isChecked ->
pollOptions[holder.bindingAdapterPosition].selected = isChecked pollOptions[holder.bindingAdapterPosition].selected = isChecked

View file

@ -20,6 +20,8 @@ import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.ContextCompat;
import androidx.core.text.HtmlCompat; import androidx.core.text.HtmlCompat;
import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
@ -27,6 +29,7 @@ import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import com.bumptech.glide.RequestBuilder; import com.bumptech.glide.RequestBuilder;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.bitmap.CenterCrop; import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners; import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners;
import com.google.android.material.button.MaterialButton; import com.google.android.material.button.MaterialButton;
@ -40,6 +43,7 @@ import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.HashTag; import com.keylesspalace.tusky.entity.HashTag;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper;
@ -54,10 +58,8 @@ import com.keylesspalace.tusky.viewdata.PollViewDataKt;
import com.keylesspalace.tusky.viewdata.StatusViewData; import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.text.NumberFormat; import java.text.NumberFormat;
import java.text.SimpleDateFormat;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Locale;
import at.connyduck.sparkbutton.SparkButton; import at.connyduck.sparkbutton.SparkButton;
import at.connyduck.sparkbutton.helpers.Utils; import at.connyduck.sparkbutton.helpers.Utils;
@ -77,6 +79,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private SparkButton favouriteButton; private SparkButton favouriteButton;
private SparkButton bookmarkButton; private SparkButton bookmarkButton;
private ImageButton moreButton; private ImageButton moreButton;
private ConstraintLayout mediaContainer;
protected MediaPreviewImageView[] mediaPreviews; protected MediaPreviewImageView[] mediaPreviews;
private ImageView[] mediaOverlays; private ImageView[] mediaOverlays;
private TextView sensitiveMediaWarning; private TextView sensitiveMediaWarning;
@ -103,10 +106,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private TextView cardUrl; private TextView cardUrl;
private PollAdapter pollAdapter; private PollAdapter pollAdapter;
private SimpleDateFormat shortSdf;
private SimpleDateFormat longSdf;
private final NumberFormat numberFormat = NumberFormat.getNumberInstance(); private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
protected int avatarRadius48dp; protected int avatarRadius48dp;
private int avatarRadius36dp; private int avatarRadius36dp;
@ -127,7 +128,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
bookmarkButton = itemView.findViewById(R.id.status_bookmark); bookmarkButton = itemView.findViewById(R.id.status_bookmark);
moreButton = itemView.findViewById(R.id.status_more); moreButton = itemView.findViewById(R.id.status_more);
itemView.findViewById(R.id.status_media_preview_container).setClipToOutline(true); mediaContainer = itemView.findViewById(R.id.status_media_preview_container);
mediaContainer.setClipToOutline(true);
mediaPreviews = new MediaPreviewImageView[]{ mediaPreviews = new MediaPreviewImageView[]{
itemView.findViewById(R.id.status_media_preview_0), itemView.findViewById(R.id.status_media_preview_0),
@ -170,9 +172,6 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext())); pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext()));
((DefaultItemAnimator) pollOptions.getItemAnimator()).setSupportsChangeAnimations(false); ((DefaultItemAnimator) pollOptions.getItemAnimator()).setSupportsChangeAnimations(false);
this.shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault());
this.longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault());
this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp);
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp);
@ -290,11 +289,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (statusDisplayOptions.showBotOverlay() && isBot) { if (statusDisplayOptions.showBotOverlay() && isBot) {
avatarInset.setVisibility(View.VISIBLE); avatarInset.setVisibility(View.VISIBLE);
avatarInset.setBackgroundColor(0x50ffffff);
Glide.with(avatarInset) Glide.with(avatarInset)
.load(R.drawable.ic_bot_24dp) // passing the drawable id directly into .load() ignores night mode https://github.com/bumptech/glide/issues/4692
.load(ContextCompat.getDrawable(avatarInset.getContext(), R.drawable.bot_badge))
.into(avatarInset); .into(avatarInset);
} else { } else {
avatarInset.setVisibility(View.GONE); avatarInset.setVisibility(View.GONE);
} }
@ -320,7 +318,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
protected void setCreatedAt(Date createdAt, StatusDisplayOptions statusDisplayOptions) { protected void setCreatedAt(Date createdAt, StatusDisplayOptions statusDisplayOptions) {
if (statusDisplayOptions.useAbsoluteTime()) { if (statusDisplayOptions.useAbsoluteTime()) {
timestampInfo.setText(getAbsoluteTime(createdAt)); timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true));
} else { } else {
if (createdAt == null) { if (createdAt == null) {
timestampInfo.setText("?m"); timestampInfo.setText("?m");
@ -333,21 +331,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
} }
} }
private String getAbsoluteTime(Date createdAt) {
if (createdAt == null) {
return "??:??:??";
}
if (DateUtils.isToday(createdAt.getTime())) {
return shortSdf.format(createdAt);
} else {
return longSdf.format(createdAt);
}
}
private CharSequence getCreatedAtDescription(Date createdAt, private CharSequence getCreatedAtDescription(Date createdAt,
StatusDisplayOptions statusDisplayOptions) { StatusDisplayOptions statusDisplayOptions) {
if (statusDisplayOptions.useAbsoluteTime()) { if (statusDisplayOptions.useAbsoluteTime()) {
return getAbsoluteTime(createdAt); return absoluteTimeFormatter.format(createdAt, true);
} else { } else {
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
* as 17 meters instead of minutes. */ * as 17 meters instead of minutes. */
@ -736,9 +723,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
this.setupWithStatus(status, listener, statusDisplayOptions, null); this.setupWithStatus(status, listener, statusDisplayOptions, null);
} }
public void setupWithStatus(StatusViewData.Concrete status, public void setupWithStatus(@NonNull StatusViewData.Concrete status,
final StatusActionListener listener, @NonNull final StatusActionListener listener,
StatusDisplayOptions statusDisplayOptions, @NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) { @Nullable Object payloads) {
if (payloads == null) { if (payloads == null) {
Status actionable = status.getActionable(); Status actionable = status.getActionable();
@ -1028,7 +1015,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
return votesText; return votesText;
} else { } else {
if (statusDisplayOptions.useAbsoluteTime()) { if (statusDisplayOptions.useAbsoluteTime()) {
pollDurationInfo = context.getString(R.string.poll_info_time_absolute, getAbsoluteTime(poll.getExpiresAt())); pollDurationInfo = context.getString(R.string.poll_info_time_absolute, absoluteTimeFormatter.format(poll.getExpiresAt(), false));
} else { } else {
pollDurationInfo = TimestampUtils.formatPollDuration(pollDescription.getContext(), poll.getExpiresAt().getTime(), timestamp); pollDurationInfo = TimestampUtils.formatPollDuration(pollDescription.getContext(), poll.getExpiresAt().getTime(), timestamp);
} }
@ -1043,9 +1030,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
StatusDisplayOptions statusDisplayOptions, StatusDisplayOptions statusDisplayOptions,
final StatusActionListener listener final StatusActionListener listener
) { ) {
final Card card = status.getActionable().getCard(); final Status actionable = status.getActionable();
final Card card = actionable.getCard();
if (cardViewMode != CardViewMode.NONE && if (cardViewMode != CardViewMode.NONE &&
status.getActionable().getAttachments().size() == 0 && actionable.getAttachments().size() == 0 &&
actionable.getPoll() == null &&
card != null && card != null &&
!TextUtils.isEmpty(card.getUrl()) && !TextUtils.isEmpty(card.getUrl()) &&
(!status.isCollapsible() || !status.isCollapsed())) { (!status.isCollapsible() || !status.isCollapsed())) {
@ -1067,7 +1056,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
// Statuses from other activitypub sources can be marked sensitive even if there's no media, // Statuses from other activitypub sources can be marked sensitive even if there's no media,
// so let's blur the preview in that case // so let's blur the preview in that case
// If media previews are disabled, show placeholder for cards as well // If media previews are disabled, show placeholder for cards as well
if (statusDisplayOptions.mediaPreviewEnabled() && !status.getActionable().getSensitive() && !TextUtils.isEmpty(card.getImage())) { if (statusDisplayOptions.mediaPreviewEnabled() && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) {
int topLeftRadius = 0; int topLeftRadius = 0;
int topRightRadius = 0; int topRightRadius = 0;
@ -1148,6 +1137,28 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
} }
} }
public void showStatusContent(boolean show) {
int visibility = show ? View.VISIBLE : View.GONE;
avatar.setVisibility(visibility);
avatarInset.setVisibility(visibility);
displayName.setVisibility(visibility);
username.setVisibility(visibility);
timestampInfo.setVisibility(visibility);
contentWarningDescription.setVisibility(visibility);
contentWarningButton.setVisibility(visibility);
content.setVisibility(visibility);
cardView.setVisibility(visibility);
mediaContainer.setVisibility(visibility);
pollOptions.setVisibility(visibility);
pollButton.setVisibility(visibility);
pollDescription.setVisibility(visibility);
replyButton.setVisibility(visibility);
reblogButton.setVisibility(visibility);
favouriteButton.setVisibility(visibility);
bookmarkButton.setVisibility(visibility);
moreButton.setVisibility(visibility);
}
private static String formatDuration(double durationInSeconds) { private static String formatDuration(double durationInSeconds) {
int seconds = (int) Math.round(durationInSeconds) % 60; int seconds = (int) Math.round(durationInSeconds) % 60;
int minutes = (int) durationInSeconds % 3600 / 60; int minutes = (int) durationInSeconds % 3600 / 60;

View file

@ -1,14 +1,12 @@
package com.keylesspalace.tusky.adapter; package com.keylesspalace.tusky.adapter;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context; import android.content.Context;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import android.text.method.LinkMovementMethod; import android.text.method.LinkMovementMethod;
import android.view.View; import android.view.View;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
@ -101,10 +99,10 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
} }
@Override @Override
public void setupWithStatus(final StatusViewData.Concrete status, public void setupWithStatus(@NonNull final StatusViewData.Concrete status,
final StatusActionListener listener, @NonNull final StatusActionListener listener,
StatusDisplayOptions statusDisplayOptions, @NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) { @Nullable Object payloads) {
super.setupWithStatus(status, listener, statusDisplayOptions, payloads); super.setupWithStatus(status, listener, statusDisplayOptions, payloads);
setupCard(status, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status setupCard(status, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
if (payloads == null) { if (payloads == null) {
@ -118,19 +116,6 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
setApplication(status.getActionable().getApplication()); setApplication(status.getActionable().getApplication());
View.OnLongClickListener longClickListener = view -> {
TextView textView = (TextView) view;
ClipboardManager clipboard = (ClipboardManager) view.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = ClipData.newPlainText("toot", textView.getText());
clipboard.setPrimaryClip(clip);
Toast.makeText(view.getContext(), R.string.copy_to_clipboard_success, Toast.LENGTH_SHORT).show();
return true;
};
content.setOnLongClickListener(longClickListener);
contentWarningDescription.setOnLongClickListener(longClickListener);
setStatusVisibility(status.getActionable().getVisibility()); setStatusVisibility(status.getActionable().getVisibility());
} }
} }

View file

@ -22,6 +22,7 @@ import android.view.View;
import android.widget.Button; import android.widget.Button;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
@ -58,9 +59,9 @@ public class StatusViewHolder extends StatusBaseViewHolder {
} }
@Override @Override
public void setupWithStatus(StatusViewData.Concrete status, public void setupWithStatus(@NonNull StatusViewData.Concrete status,
final StatusActionListener listener, @NonNull final StatusActionListener listener,
StatusDisplayOptions statusDisplayOptions, @NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) { @Nullable Object payloads) {
if (payloads == null) { if (payloads == null) {
@ -129,4 +130,9 @@ public class StatusViewHolder extends StatusBaseViewHolder {
content.setFilters(NO_INPUT_FILTER); content.setFilters(NO_INPUT_FILTER);
} }
} }
public void showStatusContent(boolean show) {
super.showStatusContent(show);
contentCollapseButton.setVisibility(show ? View.VISIBLE : View.GONE);
}
} }

View file

@ -20,8 +20,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.Color import android.graphics.Color
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.os.Bundle import android.os.Bundle
import android.text.Editable import android.text.Editable
import android.view.Menu import android.view.Menu
@ -39,7 +37,6 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.emoji.text.EmojiCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager2.widget.MarginPageTransformer import androidx.viewpager2.widget.MarginPageTransformer
@ -78,7 +75,7 @@ import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.getDomain import com.keylesspalace.tusky.util.getDomain
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
@ -374,13 +371,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
.show() .show()
} }
} }
viewModel.accountFieldData.observe(
this,
{
accountFieldAdapter.fields = it
accountFieldAdapter.notifyDataSetChanged()
}
)
viewModel.noteSaved.observe(this) { viewModel.noteSaved.observe(this) {
binding.saveNoteInfo.visible(it, View.INVISIBLE) binding.saveNoteInfo.visible(it, View.INVISIBLE)
} }
@ -395,11 +385,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
adapter.refreshContent() adapter.refreshContent()
} }
viewModel.isRefreshing.observe( viewModel.isRefreshing.observe(
this, this
{ isRefreshing -> ) { isRefreshing ->
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
} }
)
binding.swipeToRefreshLayout.setColorSchemeResources(R.color.chinwag_green) binding.swipeToRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
} }
@ -410,10 +399,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountUsernameTextView.text = usernameFormatted binding.accountUsernameTextView.text = usernameFormatted
binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis) binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis)
val emojifiedNote = account.note.emojify(account.emojis, binding.accountNoteTextView, animateEmojis) val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this) setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this)
// accountFieldAdapter.fields = account.fields ?: emptyList() accountFieldAdapter.fields = account.fields ?: emptyList()
accountFieldAdapter.emojis = account.emojis ?: emptyList() accountFieldAdapter.emojis = account.emojis ?: emptyList()
accountFieldAdapter.notifyDataSetChanged() accountFieldAdapter.notifyDataSetChanged()
@ -469,14 +458,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
*/ */
private fun updateToolbar() { private fun updateToolbar() {
loadedAccount?.let { account -> loadedAccount?.let { account ->
supportActionBar?.title = account.name.emojify(account.emojis, binding.accountToolbar, animateEmojis)
val emojifiedName = account.name.emojify(account.emojis, binding.accountToolbar, animateEmojis)
try {
supportActionBar?.title = EmojiCompat.get().process(emojifiedName)
} catch (e: IllegalStateException) {
supportActionBar?.title = emojifiedName
}
supportActionBar?.subtitle = String.format(getString(R.string.post_username_format), account.username) supportActionBar?.subtitle = String.format(getString(R.string.post_username_format), account.username)
} }
} }
@ -501,13 +483,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
loadAvatar(movedAccount.avatar, binding.accountMovedAvatar, avatarRadius, animateAvatar) loadAvatar(movedAccount.avatar, binding.accountMovedAvatar, avatarRadius, animateAvatar)
binding.accountMovedText.text = getString(R.string.account_moved_description, movedAccount.name) binding.accountMovedText.text = getString(R.string.account_moved_description, movedAccount.name)
// this is necessary because API 19 can't handle vector compound drawables
val movedIcon = ContextCompat.getDrawable(this, R.drawable.ic_briefcase)?.mutate()
val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
movedIcon?.colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
binding.accountMovedText.setCompoundDrawablesRelativeWithIntrinsicBounds(movedIcon, null, null, null)
} }
} }

View file

@ -15,7 +15,6 @@
package com.keylesspalace.tusky.components.account package com.keylesspalace.tusky.components.account
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -23,12 +22,10 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAccountFieldBinding import com.keylesspalace.tusky.databinding.ItemAccountFieldBinding
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Field import com.keylesspalace.tusky.entity.Field
import com.keylesspalace.tusky.entity.IdentityProof
import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.createClickableText
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.setClickableText
class AccountFieldAdapter( class AccountFieldAdapter(
@ -37,7 +34,7 @@ class AccountFieldAdapter(
) : RecyclerView.Adapter<BindingHolder<ItemAccountFieldBinding>>() { ) : RecyclerView.Adapter<BindingHolder<ItemAccountFieldBinding>>() {
var emojis: List<Emoji> = emptyList() var emojis: List<Emoji> = emptyList()
var fields: List<Either<IdentityProof, Field>> = emptyList() var fields: List<Field> = emptyList()
override fun getItemCount() = fields.size override fun getItemCount() = fields.size
@ -47,32 +44,20 @@ class AccountFieldAdapter(
} }
override fun onBindViewHolder(holder: BindingHolder<ItemAccountFieldBinding>, position: Int) { override fun onBindViewHolder(holder: BindingHolder<ItemAccountFieldBinding>, position: Int) {
val proofOrField = fields[position] val field = fields[position]
val nameTextView = holder.binding.accountFieldName val nameTextView = holder.binding.accountFieldName
val valueTextView = holder.binding.accountFieldValue val valueTextView = holder.binding.accountFieldValue
if (proofOrField.isLeft()) { val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis)
val identityProof = proofOrField.asLeft() nameTextView.text = emojifiedName
nameTextView.text = identityProof.provider val emojifiedValue = field.value.parseAsMastodonHtml().emojify(emojis, valueTextView, animateEmojis)
valueTextView.text = createClickableText(identityProof.username, identityProof.profileUrl) setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener)
valueTextView.movementMethod = LinkMovementMethod.getInstance()
if (field.verifiedAt != null) {
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)
} else { } else {
val field = proofOrField.asRight() valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis)
nameTextView.text = emojifiedName
val emojifiedValue = field.value.emojify(emojis, valueTextView, animateEmojis)
setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener)
if (field.verifiedAt != null) {
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)
} else {
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
}
} }
} }
} }

View file

@ -10,17 +10,13 @@ import com.keylesspalace.tusky.appstore.ProfileEditedEvent
import com.keylesspalace.tusky.appstore.UnfollowEvent import com.keylesspalace.tusky.appstore.UnfollowEvent
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Field
import com.keylesspalace.tusky.entity.IdentityProof
import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.combineOptionalLiveData
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import retrofit2.Call import retrofit2.Call
@ -40,13 +36,6 @@ class AccountViewModel @Inject constructor(
val noteSaved = MutableLiveData<Boolean>() val noteSaved = MutableLiveData<Boolean>()
private val identityProofData = MutableLiveData<List<IdentityProof>>()
val accountFieldData = combineOptionalLiveData(accountData, identityProofData) { accountRes, identityProofs ->
identityProofs.orEmpty().map { Either.Left<IdentityProof, Field>(it) }
.plus(accountRes?.data?.fields.orEmpty().map { Either.Right(it) })
}
val isRefreshing = MutableLiveData<Boolean>().apply { value = false } val isRefreshing = MutableLiveData<Boolean>().apply { value = false }
private var isDataLoading = false private var isDataLoading = false
@ -106,22 +95,6 @@ class AccountViewModel @Inject constructor(
} }
} }
private fun obtainIdentityProof(reload: Boolean = false) {
if (identityProofData.value == null || reload) {
mastodonApi.identityProofs(accountId)
.subscribe(
{ proofs ->
identityProofData.postValue(proofs)
},
{ t ->
Log.w(TAG, "failed obtaining identity proofs", t)
}
)
.autoDispose()
}
}
fun changeFollowState() { fun changeFollowState() {
val relationship = relationshipData.value?.data val relationship = relationshipData.value?.data
if (relationship?.following == true || relationship?.requested == true) { if (relationship?.following == true || relationship?.requested == true) {
@ -314,7 +287,6 @@ class AccountViewModel @Inject constructor(
return return
accountId.let { accountId.let {
obtainAccount(isReload) obtainAccount(isReload)
obtainIdentityProof()
if (!isSelf) if (!isSelf)
obtainRelationship(isReload) obtainRelationship(isReload)
} }

View file

@ -32,6 +32,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.EmojiSpan import com.keylesspalace.tusky.util.EmojiSpan
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.setClickableText
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
@ -60,7 +61,7 @@ class AnnouncementAdapter(
val chips = holder.binding.chipGroup val chips = holder.binding.chipGroup
val addReactionChip = holder.binding.addReactionChip val addReactionChip = holder.binding.addReactionChip
val emojifiedText: CharSequence = item.content.emojify(item.emojis, text, animateEmojis) val emojifiedText: CharSequence = item.content.parseAsMastodonHtml().emojify(item.emojis, text, animateEmojis)
setClickableText(text, emojifiedText, item.mentions, item.tags, listener) setClickableText(text, emojifiedText, item.mentions, item.tags, listener)

View file

@ -18,31 +18,26 @@ package com.keylesspalace.tusky.components.announcements
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.compose.DEFAULT_MAXIMUM_URL_LENGTH import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.InstanceEntity
import com.keylesspalace.tusky.entity.Announcement import com.keylesspalace.tusky.entity.Announcement
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.Success
import io.reactivex.rxjava3.core.Single import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class AnnouncementsViewModel @Inject constructor( class AnnouncementsViewModel @Inject constructor(
accountManager: AccountManager, private val instanceInfoRepo: InstanceInfoRepository,
private val appDatabase: AppDatabase,
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
private val eventHub: EventHub private val eventHub: EventHub
) : RxAwareViewModel() { ) : ViewModel() {
private val announcementsMutable = MutableLiveData<Resource<List<Announcement>>>() private val announcementsMutable = MutableLiveData<Resource<List<Announcement>>>()
val announcements: LiveData<Resource<List<Announcement>>> = announcementsMutable val announcements: LiveData<Resource<List<Announcement>>> = announcementsMutable
@ -51,155 +46,130 @@ class AnnouncementsViewModel @Inject constructor(
val emojis: LiveData<List<Emoji>> = emojisMutable val emojis: LiveData<List<Emoji>> = emojisMutable
init { init {
Single.zip( viewModelScope.launch {
mastodonApi.getCustomEmojis(), emojisMutable.postValue(instanceInfoRepo.getEmojis())
appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
.map<Either<InstanceEntity, Instance>> { Either.Left(it) }
.onErrorResumeNext {
mastodonApi.getInstance()
.map { Either.Right(it) }
}
) { emojis, either ->
either.asLeftOrNull()?.copy(emojiList = emojis)
?: InstanceEntity(
accountManager.activeAccount?.domain!!,
emojis,
either.asRight().configuration?.statuses?.maxCharacters ?: either.asRight().maxTootChars,
either.asRight().configuration?.polls?.maxOptions ?: either.asRight().pollConfiguration?.maxOptions,
either.asRight().configuration?.polls?.maxCharactersPerOption ?: either.asRight().pollConfiguration?.maxOptionChars,
either.asRight().configuration?.polls?.minExpiration ?: either.asRight().pollConfiguration?.minExpiration,
either.asRight().configuration?.polls?.maxExpiration ?: either.asRight().pollConfiguration?.maxExpiration,
either.asRight().configuration?.statuses?.charactersReservedPerUrl ?: DEFAULT_MAXIMUM_URL_LENGTH,
either.asRight().version
)
} }
.doOnSuccess {
appDatabase.instanceDao().insertOrReplace(it)
}
.subscribe(
{
emojisMutable.postValue(it.emojiList.orEmpty())
},
{
Log.w(TAG, "Failed to get custom emojis.", it)
}
)
.autoDispose()
} }
fun load() { fun load() {
announcementsMutable.postValue(Loading()) viewModelScope.launch {
mastodonApi.listAnnouncements() announcementsMutable.postValue(Loading())
.subscribe( mastodonApi.listAnnouncements()
{ .fold(
announcementsMutable.postValue(Success(it)) {
it.filter { announcement -> !announcement.read } announcementsMutable.postValue(Success(it))
.forEach { announcement -> it.filter { announcement -> !announcement.read }
mastodonApi.dismissAnnouncement(announcement.id) .forEach { announcement ->
.subscribe( mastodonApi.dismissAnnouncement(announcement.id)
{ .fold(
eventHub.dispatch(AnnouncementReadEvent(announcement.id)) {
}, eventHub.dispatch(AnnouncementReadEvent(announcement.id))
{ throwable -> },
Log.d(TAG, "Failed to mark announcement as read.", throwable) { throwable ->
} Log.d(
) TAG,
.autoDispose() "Failed to mark announcement as read.",
} throwable
}, )
{ }
announcementsMutable.postValue(Error(cause = it)) )
} }
) },
.autoDispose() {
announcementsMutable.postValue(Error(cause = it))
}
)
}
} }
fun addReaction(announcementId: String, name: String) { fun addReaction(announcementId: String, name: String) {
mastodonApi.addAnnouncementReaction(announcementId, name) viewModelScope.launch {
.subscribe( mastodonApi.addAnnouncementReaction(announcementId, name)
{ .fold(
announcementsMutable.postValue( {
Success( announcementsMutable.postValue(
announcements.value!!.data!!.map { announcement -> Success(
if (announcement.id == announcementId) { announcements.value!!.data!!.map { announcement ->
announcement.copy( if (announcement.id == announcementId) {
reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) { announcement.copy(
announcement.reactions.map { reaction -> reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) {
announcement.reactions.map { reaction ->
if (reaction.name == name) {
reaction.copy(
count = reaction.count + 1,
me = true
)
} else {
reaction
}
}
} else {
listOf(
*announcement.reactions.toTypedArray(),
emojis.value!!.find { emoji -> emoji.shortcode == name }
!!.run {
Announcement.Reaction(
name,
1,
true,
url,
staticUrl
)
}
)
}
)
} else {
announcement
}
}
)
)
},
{
Log.w(TAG, "Failed to add reaction to the announcement.", it)
}
)
}
}
fun removeReaction(announcementId: String, name: String) {
viewModelScope.launch {
mastodonApi.removeAnnouncementReaction(announcementId, name)
.fold(
{
announcementsMutable.postValue(
Success(
announcements.value!!.data!!.map { announcement ->
if (announcement.id == announcementId) {
announcement.copy(
reactions = announcement.reactions.mapNotNull { reaction ->
if (reaction.name == name) { if (reaction.name == name) {
reaction.copy( if (reaction.count > 1) {
count = reaction.count + 1, reaction.copy(
me = true count = reaction.count - 1,
) me = false
)
} else {
null
}
} else { } else {
reaction reaction
} }
} }
} else { )
listOf( } else {
*announcement.reactions.toTypedArray(), announcement
emojis.value!!.find { emoji -> emoji.shortcode == name } }
!!.run {
Announcement.Reaction(
name,
1,
true,
url,
staticUrl
)
}
)
}
)
} else {
announcement
} }
} )
) )
) },
}, {
{ Log.w(TAG, "Failed to remove reaction from the announcement.", it)
Log.w(TAG, "Failed to add reaction to the announcement.", it) }
} )
) }
.autoDispose()
}
fun removeReaction(announcementId: String, name: String) {
mastodonApi.removeAnnouncementReaction(announcementId, name)
.subscribe(
{
announcementsMutable.postValue(
Success(
announcements.value!!.data!!.map { announcement ->
if (announcement.id == announcementId) {
announcement.copy(
reactions = announcement.reactions.mapNotNull { reaction ->
if (reaction.name == name) {
if (reaction.count > 1) {
reaction.copy(
count = reaction.count - 1,
me = false
)
} else {
null
}
} else {
reaction
}
}
)
} else {
announcement
}
}
)
)
},
{
Log.w(TAG, "Failed to remove reaction from the announcement.", it)
}
)
.autoDispose()
} }
companion object { companion object {

View file

@ -51,6 +51,8 @@ import androidx.core.view.ContentInfoCompat
import androidx.core.view.OnReceiveContentListener import androidx.core.view.OnReceiveContentListener
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
@ -65,6 +67,7 @@ import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.databinding.ActivityComposeBinding import com.keylesspalace.tusky.databinding.ActivityComposeBinding
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.DraftAttachment import com.keylesspalace.tusky.db.DraftAttachment
@ -93,6 +96,7 @@ import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@ -123,8 +127,8 @@ class ComposeActivity :
private var photoUploadUri: Uri? = null private var photoUploadUri: Uri? = null
@VisibleForTesting @VisibleForTesting
var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT
var charactersReservedPerUrl = DEFAULT_MAXIMUM_URL_LENGTH var charactersReservedPerUrl = InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL
private val viewModel: ComposeViewModel by viewModels { viewModelFactory } private val viewModel: ComposeViewModel by viewModels { viewModelFactory }
@ -328,11 +332,10 @@ class ComposeActivity :
private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) { private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) {
withLifecycleContext { withLifecycleContext {
viewModel.instanceParams.observe { instanceData -> viewModel.instanceInfo.observe { instanceData ->
maximumTootCharacters = instanceData.maxChars maximumTootCharacters = instanceData.maxChars
charactersReservedPerUrl = instanceData.charactersReservedPerUrl charactersReservedPerUrl = instanceData.charactersReservedPerUrl
updateVisibleCharactersLeft() updateVisibleCharactersLeft()
binding.composeScheduleButton.visible(instanceData.supportsScheduled)
} }
viewModel.emoji.observe { emoji -> setEmojiList(emoji) } viewModel.emoji.observe { emoji -> setEmojiList(emoji) }
combineLiveData(viewModel.markMediaAsSensitive, viewModel.showContentWarning) { markSensitive, showContentWarning -> combineLiveData(viewModel.markMediaAsSensitive, viewModel.showContentWarning) { markSensitive, showContentWarning ->
@ -342,14 +345,17 @@ class ComposeActivity :
viewModel.statusVisibility.observe { visibility -> viewModel.statusVisibility.observe { visibility ->
setStatusVisibility(visibility) setStatusVisibility(visibility)
} }
viewModel.media.observe { media -> lifecycleScope.launch {
mediaAdapter.submitList(media) viewModel.media.collect { media ->
if (media.size != mediaCount) { mediaAdapter.submitList(media)
mediaCount = media.size if (media.size != mediaCount) {
binding.composeMediaPreviewBar.visible(media.isNotEmpty()) mediaCount = media.size
updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value != false, viewModel.showContentWarning.value != false) binding.composeMediaPreviewBar.visible(media.isNotEmpty())
updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value != false, viewModel.showContentWarning.value != false)
}
} }
} }
viewModel.poll.observe { poll -> viewModel.poll.observe { poll ->
binding.pollPreview.visible(poll != null) binding.pollPreview.visible(poll != null)
poll?.let(binding.pollPreview::setPoll) poll?.let(binding.pollPreview::setPoll)
@ -362,7 +368,7 @@ class ComposeActivity :
} }
updateScheduleButton() updateScheduleButton()
} }
combineOptionalLiveData(viewModel.media, viewModel.poll) { media, poll -> combineOptionalLiveData(viewModel.media.asLiveData(), viewModel.poll) { media, poll ->
val active = poll == null && val active = poll == null &&
media!!.size != 4 && media!!.size != 4 &&
(media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE) (media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE)
@ -666,7 +672,7 @@ class ComposeActivity :
private fun openPollDialog() { private fun openPollDialog() {
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
val instanceParams = viewModel.instanceParams.value!! val instanceParams = viewModel.instanceInfo.value!!
showAddPollDialog( showAddPollDialog(
this, viewModel.poll.value, instanceParams.pollMaxOptions, this, viewModel.poll.value, instanceParams.pollMaxOptions,
instanceParams.pollMaxLength, instanceParams.pollMinDuration, instanceParams.pollMaxDuration, instanceParams.pollMaxLength, instanceParams.pollMinDuration, instanceParams.pollMaxDuration,
@ -779,11 +785,11 @@ class ComposeActivity :
spoilerText = binding.composeContentWarningField.text.toString() spoilerText = binding.composeContentWarningField.text.toString()
} }
val characterCount = calculateTextLength() val characterCount = calculateTextLength()
if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value!!.isEmpty()) { if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value.isEmpty()) {
binding.composeEditField.error = getString(R.string.error_empty) binding.composeEditField.error = getString(R.string.error_empty)
enableButtons(true) enableButtons(true)
} else if (characterCount <= maximumTootCharacters) { } else if (characterCount <= maximumTootCharacters) {
if (viewModel.media.value!!.isNotEmpty()) { if (viewModel.media.value.isNotEmpty()) {
finishingUploadDialog = ProgressDialog.show( finishingUploadDialog = ProgressDialog.show(
this, getString(R.string.dialog_title_finishing_media_upload), this, getString(R.string.dialog_title_finishing_media_upload),
getString(R.string.dialog_message_uploading_media), true, true getString(R.string.dialog_message_uploading_media), true, true
@ -866,25 +872,15 @@ class ComposeActivity :
} }
private fun pickMedia(uri: Uri) { private fun pickMedia(uri: Uri) {
withLifecycleContext { lifecycleScope.launch {
viewModel.pickMedia(uri).observe { exceptionOrItem -> viewModel.pickMedia(uri).onFailure { throwable ->
exceptionOrItem.asLeftOrNull()?.let { val errorId = when (throwable) {
val errorId = when (it) { is VideoSizeException -> R.string.error_video_upload_size
is VideoSizeException -> { is AudioSizeException -> R.string.error_audio_upload_size
R.string.error_video_upload_size is VideoOrImageException -> R.string.error_media_upload_image_or_video
} else -> R.string.error_media_upload_opening
is AudioSizeException -> {
R.string.error_audio_upload_size
}
is VideoOrImageException -> {
R.string.error_media_upload_image_or_video
}
else -> {
R.string.error_media_upload_opening
}
}
displayTransientError(errorId)
} }
displayTransientError(errorId)
} }
} }
} }
@ -971,8 +967,19 @@ class ComposeActivity :
} }
private fun saveDraftAndFinish(contentText: String, contentWarning: String) { private fun saveDraftAndFinish(contentText: String, contentWarning: String) {
viewModel.saveDraft(contentText, contentWarning) lifecycleScope.launch {
finishWithoutSlideOutAnimation() val dialog = if (viewModel.shouldShowSaveDraftDialog()) {
ProgressDialog.show(
this@ComposeActivity, null,
getString(R.string.saving_draft), true, false
)
} else {
null
}
viewModel.saveDraft(contentText, contentWarning)
dialog?.cancel()
finishWithoutSlideOutAnimation()
}
} }
override fun search(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> { override fun search(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
@ -991,7 +998,7 @@ class ComposeActivity :
} }
data class QueuedMedia( data class QueuedMedia(
val localId: Long, val localId: Int,
val uri: Uri, val uri: Uri,
val type: Type, val type: Type,
val mediaSize: Long, val mediaSize: Long,

View file

@ -20,14 +20,15 @@ import android.util.Log
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.components.search.SearchType import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.InstanceEntity
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewPoll
@ -35,19 +36,21 @@ import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.StatusToSend import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.VersionUtils
import com.keylesspalace.tusky.util.combineLiveData import com.keylesspalace.tusky.util.combineLiveData
import com.keylesspalace.tusky.util.filter
import com.keylesspalace.tusky.util.map
import com.keylesspalace.tusky.util.randomAlphanumericString import com.keylesspalace.tusky.util.randomAlphanumericString
import com.keylesspalace.tusky.util.toLiveData import com.keylesspalace.tusky.util.toLiveData
import com.keylesspalace.tusky.util.withoutFirstWhich
import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single import kotlinx.coroutines.Dispatchers
import io.reactivex.rxjava3.disposables.Disposable import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.rxSingle
import kotlinx.coroutines.withContext
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
@ -57,8 +60,8 @@ class ComposeViewModel @Inject constructor(
private val mediaUploader: MediaUploader, private val mediaUploader: MediaUploader,
private val serviceClient: ServiceClient, private val serviceClient: ServiceClient,
private val draftHelper: DraftHelper, private val draftHelper: DraftHelper,
private val db: AppDatabase private val instanceInfoRepo: InstanceInfoRepository
) : RxAwareViewModel() { ) : ViewModel() {
private var replyingStatusAuthor: String? = null private var replyingStatusAuthor: String? = null
private var replyingStatusContent: String? = null private var replyingStatusContent: String? = null
@ -72,19 +75,8 @@ class ComposeViewModel @Inject constructor(
private var contentWarningStateChanged: Boolean = false private var contentWarningStateChanged: Boolean = false
private var modifiedInitialState: Boolean = false private var modifiedInitialState: Boolean = false
private val instance: MutableLiveData<InstanceEntity?> = MutableLiveData(null) val instanceInfo: MutableLiveData<InstanceInfo> = MutableLiveData()
val instanceParams: LiveData<ComposeInstanceParams> = instance.map { instance ->
ComposeInstanceParams(
maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
pollMinDuration = instance?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION,
pollMaxDuration = instance?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION,
charactersReservedPerUrl = instance?.charactersReservedPerUrl ?: DEFAULT_MAXIMUM_URL_LENGTH,
supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false
)
}
val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData() val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData()
val markMediaAsSensitive = val markMediaAsSensitive =
mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false) mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
@ -95,131 +87,104 @@ class ComposeViewModel @Inject constructor(
val poll: MutableLiveData<NewPoll?> = mutableLiveData(null) val poll: MutableLiveData<NewPoll?> = mutableLiveData(null)
val scheduledAt: MutableLiveData<String?> = mutableLiveData(null) val scheduledAt: MutableLiveData<String?> = mutableLiveData(null)
val media = mutableLiveData<List<QueuedMedia>>(listOf()) val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
val uploadError = MutableLiveData<Throwable>() val uploadError = MutableLiveData<Throwable>()
private val mediaToDisposable = mutableMapOf<Long, Disposable>() private val mediaToJob = mutableMapOf<Int, Job>()
private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty() private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty()
init { init {
viewModelScope.launch {
Single.zip( emoji.postValue(instanceInfoRepo.getEmojis())
api.getCustomEmojis(), api.getInstance() }
) { emojis, instance -> viewModelScope.launch {
InstanceEntity( instanceInfo.postValue(instanceInfoRepo.getInstanceInfo())
instance = accountManager.activeAccount?.domain!!,
emojiList = emojis,
maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars,
maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions,
maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars,
minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration,
maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration,
charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl,
version = instance.version
)
} }
.doOnSuccess {
db.instanceDao().insertOrReplace(it)
}
.onErrorResumeNext {
db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
}
.subscribe(
{ instanceEntity ->
emoji.postValue(instanceEntity.emojiList)
instance.postValue(instanceEntity)
},
{ throwable ->
// this can happen on network error when no cached data is available
Log.w(TAG, "error loading instance data", throwable)
}
)
.autoDispose()
} }
fun pickMedia(uri: Uri, description: String? = null): LiveData<Either<Throwable, QueuedMedia>> { suspend fun pickMedia(mediaUri: Uri, description: String? = null): Result<QueuedMedia> = withContext(Dispatchers.IO) {
// We are not calling .toLiveData() here because we don't want to stop the process when try {
// the Activity goes away temporarily (like on screen rotation). val (type, uri, size) = mediaUploader.prepareMedia(mediaUri)
val liveData = MutableLiveData<Either<Throwable, QueuedMedia>>() val mediaItems = media.value
mediaUploader.prepareMedia(uri) if (type != QueuedMedia.Type.IMAGE &&
.map { (type, uri, size) -> mediaItems.isNotEmpty() &&
val mediaItems = media.value!! mediaItems[0].type == QueuedMedia.Type.IMAGE
if (type != QueuedMedia.Type.IMAGE && ) {
mediaItems.isNotEmpty() && Result.failure(VideoOrImageException())
mediaItems[0].type == QueuedMedia.Type.IMAGE } else {
) { val queuedMedia = addMediaToQueue(type, uri, size, description)
throw VideoOrImageException() Result.success(queuedMedia)
} else {
addMediaToQueue(type, uri, size, description)
}
} }
.subscribe( } catch (e: Exception) {
{ queuedMedia -> Result.failure(e)
liveData.postValue(Either.Right(queuedMedia)) }
},
{ error ->
liveData.postValue(Either.Left(error))
}
)
.autoDispose()
return liveData
} }
private fun addMediaToQueue( private suspend fun addMediaToQueue(
type: QueuedMedia.Type, type: QueuedMedia.Type,
uri: Uri, uri: Uri,
mediaSize: Long, mediaSize: Long,
description: String? = null description: String? = null
): QueuedMedia { ): QueuedMedia {
val mediaItem = QueuedMedia( val mediaItem = media.updateAndGet { mediaValue ->
localId = System.currentTimeMillis(), val mediaItem = QueuedMedia(
uri = uri, localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1,
type = type, uri = uri,
mediaSize = mediaSize, type = type,
description = description mediaSize = mediaSize,
) description = description
media.value = media.value!! + mediaItem )
mediaToDisposable[mediaItem.localId] = mediaUploader mediaValue + mediaItem
.uploadMedia(mediaItem) }.last()
.subscribe( mediaToJob[mediaItem.localId] = viewModelScope.launch {
{ event -> mediaUploader
val item = media.value?.find { it.localId == mediaItem.localId } .uploadMedia(mediaItem)
?: return@subscribe .catch { error ->
media.update { mediaValue -> mediaValue.filter { it.localId != mediaItem.localId } }
uploadError.postValue(error)
}
.collect { event ->
val item = media.value.find { it.localId == mediaItem.localId }
?: return@collect
val newMediaItem = when (event) { val newMediaItem = when (event) {
is UploadEvent.ProgressEvent -> is UploadEvent.ProgressEvent ->
item.copy(uploadPercent = event.percentage) item.copy(uploadPercent = event.percentage)
is UploadEvent.FinishedEvent -> is UploadEvent.FinishedEvent ->
item.copy(id = event.mediaId, uploadPercent = -1) item.copy(id = event.mediaId, uploadPercent = -1)
} }
synchronized(media) { media.update { mediaValue ->
val mediaValue = media.value!! mediaValue.map { mediaItem ->
val index = mediaValue.indexOfFirst { it.localId == newMediaItem.localId } if (mediaItem.localId == newMediaItem.localId) {
media.postValue( newMediaItem
if (index == -1) {
mediaValue + newMediaItem
} else { } else {
mediaValue.toMutableList().also { it[index] = newMediaItem } mediaItem
} }
) }
} }
},
{ error ->
media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList())
uploadError.postValue(error)
} }
) }
return mediaItem return mediaItem
} }
private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?) { private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?) {
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, 0, -1, id, description) media.update { mediaValue ->
media.value = media.value!! + mediaItem val mediaItem = QueuedMedia(
localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1,
uri = uri,
type = type,
mediaSize = 0,
uploadPercent = -1,
id = id,
description = description
)
mediaValue + mediaItem
}
} }
fun removeMediaFromQueue(item: QueuedMedia) { fun removeMediaFromQueue(item: QueuedMedia) {
mediaToDisposable[item.localId]?.dispose() mediaToJob[item.localId]?.cancel()
media.value = media.value!!.withoutFirstWhich { it.localId == item.localId } media.update { mediaValue -> mediaValue.filter { it.localId != item.localId } }
} }
fun toggleMarkSensitive() { fun toggleMarkSensitive() {
@ -255,31 +220,36 @@ class ComposeViewModel @Inject constructor(
} }
} }
fun saveDraft(content: String, contentWarning: String) { fun shouldShowSaveDraftDialog(): Boolean {
viewModelScope.launch { // if any of the media files need to be downloaded first it could take a while, so show a loading dialog
val mediaUris: MutableList<String> = mutableListOf() return media.value.any { mediaValue ->
val mediaDescriptions: MutableList<String?> = mutableListOf() mediaValue.uri.scheme == "https"
media.value?.forEach { item ->
mediaUris.add(item.uri.toString())
mediaDescriptions.add(item.description)
}
draftHelper.saveDraft(
draftId = draftId,
accountId = accountManager.activeAccount?.id!!,
inReplyToId = inReplyToId,
content = content,
contentWarning = contentWarning,
sensitive = markMediaAsSensitive.value!!,
visibility = statusVisibility.value!!,
mediaUris = mediaUris,
mediaDescriptions = mediaDescriptions,
poll = poll.value,
failedToSend = false
)
} }
} }
suspend fun saveDraft(content: String, contentWarning: String) {
val mediaUris: MutableList<String> = mutableListOf()
val mediaDescriptions: MutableList<String?> = mutableListOf()
media.value.forEach { item ->
mediaUris.add(item.uri.toString())
mediaDescriptions.add(item.description)
}
draftHelper.saveDraft(
draftId = draftId,
accountId = accountManager.activeAccount?.id!!,
inReplyToId = inReplyToId,
content = content,
contentWarning = contentWarning,
sensitive = markMediaAsSensitive.value!!,
visibility = statusVisibility.value!!,
mediaUris = mediaUris,
mediaDescriptions = mediaDescriptions,
poll = poll.value,
failedToSend = false
)
}
/** /**
* Send status to the server. * Send status to the server.
* Uses current state plus provided arguments. * Uses current state plus provided arguments.
@ -291,21 +261,23 @@ class ComposeViewModel @Inject constructor(
): LiveData<Unit> { ): LiveData<Unit> {
val deletionObservable = if (isEditingScheduledToot) { val deletionObservable = if (isEditingScheduledToot) {
api.deleteScheduledStatus(scheduledTootId.toString()).toObservable().map { } rxSingle { api.deleteScheduledStatus(scheduledTootId.toString()) }.toObservable().map { }
} else { } else {
Observable.just(Unit) Observable.just(Unit)
}.toLiveData() }.toLiveData()
val sendObservable = media val sendFlow = media
.filter { items -> items.all { it.uploadPercent == -1 } } .filter { items -> items.all { it.uploadPercent == -1 } }
.map { .map {
val mediaIds = ArrayList<String>() val mediaIds: MutableList<String> = mutableListOf()
val mediaUris = ArrayList<Uri>() val mediaUris: MutableList<Uri> = mutableListOf()
val mediaDescriptions = ArrayList<String>() val mediaDescriptions: MutableList<String> = mutableListOf()
for (item in media.value!!) { val mediaProcessed: MutableList<Boolean> = mutableListOf()
for (item in media.value) {
mediaIds.add(item.id!!) mediaIds.add(item.id!!)
mediaUris.add(item.uri) mediaUris.add(item.uri)
mediaDescriptions.add(item.description ?: "") mediaDescriptions.add(item.description ?: "")
mediaProcessed.add(false)
} }
val tootToSend = StatusToSend( val tootToSend = StatusToSend(
@ -324,44 +296,38 @@ class ComposeViewModel @Inject constructor(
accountId = accountManager.activeAccount!!.id, accountId = accountManager.activeAccount!!.id,
draftId = draftId, draftId = draftId,
idempotencyKey = randomAlphanumericString(16), idempotencyKey = randomAlphanumericString(16),
retries = 0 retries = 0,
mediaProcessed = mediaProcessed
) )
serviceClient.sendToot(tootToSend) serviceClient.sendToot(tootToSend)
} }
return combineLiveData(deletionObservable, sendObservable) { _, _ -> } return combineLiveData(deletionObservable, sendFlow.asLiveData()) { _, _ -> }
} }
fun updateDescription(localId: Long, description: String): LiveData<Boolean> { suspend fun updateDescription(localId: Int, description: String): Boolean {
val newList = media.value!!.toMutableList() val newMediaList = media.updateAndGet { mediaValue ->
val index = newList.indexOfFirst { it.localId == localId } mediaValue.map { mediaItem ->
if (index != -1) { if (mediaItem.localId == localId) {
newList[index] = newList[index].copy(description = description) mediaItem.copy(description = description)
} } else {
media.value = newList mediaItem
val completedCaptioningLiveData = MutableLiveData<Boolean>()
media.observeForever(object : Observer<List<QueuedMedia>> {
override fun onChanged(mediaItems: List<QueuedMedia>) {
val updatedItem = mediaItems.find { it.localId == localId }
if (updatedItem == null) {
media.removeObserver(this)
} else if (updatedItem.id != null) {
api.updateMedia(updatedItem.id, description)
.subscribe(
{
completedCaptioningLiveData.postValue(true)
},
{
completedCaptioningLiveData.postValue(false)
}
)
.autoDispose()
media.removeObserver(this)
} }
} }
}) }
return completedCaptioningLiveData
val updatedItem = newMediaList.find { it.localId == localId }
if (updatedItem?.id != null) {
return api.updateMedia(updatedItem.id, description)
.fold({
true
}, { throwable ->
Log.w(TAG, "failed to update media", throwable)
false
})
}
return true
} }
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> { fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
@ -443,7 +409,11 @@ class ComposeViewModel @Inject constructor(
val draftAttachments = composeOptions?.draftAttachments val draftAttachments = composeOptions?.draftAttachments
if (draftAttachments != null) { if (draftAttachments != null) {
// when coming from DraftActivity // when coming from DraftActivity
draftAttachments.forEach { attachment -> pickMedia(attachment.uri, attachment.description) } viewModelScope.launch {
draftAttachments.forEach { attachment ->
pickMedia(attachment.uri, attachment.description)
}
}
} else composeOptions?.mediaAttachments?.forEach { a -> } else composeOptions?.mediaAttachments?.forEach { a ->
// when coming from redraft or ScheduledTootActivity // when coming from redraft or ScheduledTootActivity
val mediaType = when (a.type) { val mediaType = when (a.type) {
@ -494,13 +464,6 @@ class ComposeViewModel @Inject constructor(
scheduledAt.value = newScheduledAt scheduledAt.value = newScheduledAt
} }
override fun onCleared() {
for (uploadDisposable in mediaToDisposable.values) {
uploadDisposable.dispose()
}
super.onCleared()
}
private companion object { private companion object {
const val TAG = "ComposeViewModel" const val TAG = "ComposeViewModel"
} }
@ -508,25 +471,6 @@ class ComposeViewModel @Inject constructor(
fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = default } fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = default }
const val DEFAULT_CHARACTER_LIMIT = 500
private const val DEFAULT_MAX_OPTION_COUNT = 4
private const val DEFAULT_MAX_OPTION_LENGTH = 50
private const val DEFAULT_MIN_POLL_DURATION = 300
private const val DEFAULT_MAX_POLL_DURATION = 604800
// Mastodon only counts URLs as this long in terms of status character limits
const val DEFAULT_MAXIMUM_URL_LENGTH = 23
data class ComposeInstanceParams(
val maxChars: Int,
val pollMaxOptions: Int,
val pollMaxLength: Int,
val pollMinDuration: Int,
val pollMaxDuration: Int,
val charactersReservedPerUrl: Int,
val supportsScheduled: Boolean
)
/** /**
* Thrown when trying to add an image when video is already present or the other way around * Thrown when trying to add an image when video is already present or the other way around
*/ */

View file

@ -1,154 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.compose;
import android.content.ContentResolver;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.os.AsyncTask;
import com.keylesspalace.tusky.util.IOUtils;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import static com.keylesspalace.tusky.util.MediaUtilsKt.calculateInSampleSize;
import static com.keylesspalace.tusky.util.MediaUtilsKt.getImageOrientation;
import static com.keylesspalace.tusky.util.MediaUtilsKt.reorientBitmap;
/**
* Reduces the file size of images to fit under a given limit by resizing them, maintaining both
* aspect ratio and orientation.
*/
public class DownsizeImageTask extends AsyncTask<Uri, Void, Boolean> {
private int sizeLimit;
private ContentResolver contentResolver;
private Listener listener;
private File tempFile;
/**
* @param sizeLimit the maximum number of bytes each image can take
* @param contentResolver to resolve the specified images' URIs
* @param tempFile the file where the result will be stored
* @param listener to whom the results are given
*/
public DownsizeImageTask(int sizeLimit, ContentResolver contentResolver, File tempFile, Listener listener) {
this.sizeLimit = sizeLimit;
this.contentResolver = contentResolver;
this.tempFile = tempFile;
this.listener = listener;
}
@Override
protected Boolean doInBackground(Uri... uris) {
boolean result = DownsizeImageTask.resize(uris, sizeLimit, contentResolver, tempFile);
if (isCancelled()) {
return false;
}
return result;
}
@Override
protected void onPostExecute(Boolean successful) {
if (successful) {
listener.onSuccess(tempFile);
} else {
listener.onFailure();
}
super.onPostExecute(successful);
}
public static boolean resize(Uri[] uris, int sizeLimit, ContentResolver contentResolver,
File tempFile) {
for (Uri uri : uris) {
InputStream inputStream;
try {
inputStream = contentResolver.openInputStream(uri);
} catch (FileNotFoundException e) {
return false;
}
// Initially, just get the image dimensions.
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeStream(inputStream, null, options);
IOUtils.closeQuietly(inputStream);
// Get EXIF data, for orientation info.
int orientation = getImageOrientation(uri, contentResolver);
/* Unfortunately, there isn't a determined worst case compression ratio for image
* formats. So, the only way to tell if they're too big is to compress them and
* test, and keep trying at smaller sizes. The initial estimate should be good for
* many cases, so it should only iterate once, but the loop is used to be absolutely
* sure it gets downsized to below the limit. */
int scaledImageSize = 1024;
do {
OutputStream stream;
try {
stream = new FileOutputStream(tempFile);
} catch (FileNotFoundException e) {
return false;
}
try {
inputStream = contentResolver.openInputStream(uri);
} catch (FileNotFoundException e) {
return false;
}
options.inSampleSize = calculateInSampleSize(options, scaledImageSize, scaledImageSize);
options.inJustDecodeBounds = false;
Bitmap scaledBitmap;
try {
scaledBitmap = BitmapFactory.decodeStream(inputStream, null, options);
} catch (OutOfMemoryError error) {
return false;
} finally {
IOUtils.closeQuietly(inputStream);
}
if (scaledBitmap == null) {
return false;
}
Bitmap reorientedBitmap = reorientBitmap(scaledBitmap, orientation);
if (reorientedBitmap == null) {
scaledBitmap.recycle();
return false;
}
Bitmap.CompressFormat format;
/* It's not likely the user will give transparent images over the upload limit, but
* if they do, make sure the transparency is retained. */
if (!reorientedBitmap.hasAlpha()) {
format = Bitmap.CompressFormat.JPEG;
} else {
format = Bitmap.CompressFormat.PNG;
}
reorientedBitmap.compress(format, 85, stream);
reorientedBitmap.recycle();
scaledImageSize /= 2;
} while (tempFile.length() > sizeLimit);
}
return true;
}
/**
* Used to communicate the results of the task.
*/
public interface Listener {
void onSuccess(File file);
void onFailure();
}
}

View file

@ -0,0 +1,101 @@
/* Copyright 2022 Tusky contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.compose
import android.content.ContentResolver
import android.graphics.Bitmap
import android.graphics.Bitmap.CompressFormat
import android.graphics.BitmapFactory
import android.net.Uri
import com.keylesspalace.tusky.util.IOUtils
import com.keylesspalace.tusky.util.calculateInSampleSize
import com.keylesspalace.tusky.util.getImageOrientation
import com.keylesspalace.tusky.util.reorientBitmap
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
/**
* @param uri the uri pointing to the input file
* @param sizeLimit the maximum number of bytes the output image is allowed to have
* @param contentResolver to resolve the specified input uri
* @param tempFile the file where the result will be stored
* @return true when the image was successfully resized, false otherwise
*/
fun downsizeImage(
uri: Uri,
sizeLimit: Int,
contentResolver: ContentResolver,
tempFile: File
): Boolean {
val decodeBoundsInputStream = try {
contentResolver.openInputStream(uri)
} catch (e: FileNotFoundException) {
return false
}
// Initially, just get the image dimensions.
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeStream(decodeBoundsInputStream, null, options)
IOUtils.closeQuietly(decodeBoundsInputStream)
// Get EXIF data, for orientation info.
val orientation = getImageOrientation(uri, contentResolver)
/* Unfortunately, there isn't a determined worst case compression ratio for image
* formats. So, the only way to tell if they're too big is to compress them and
* test, and keep trying at smaller sizes. The initial estimate should be good for
* many cases, so it should only iterate once, but the loop is used to be absolutely
* sure it gets downsized to below the limit. */
var scaledImageSize = 1024
do {
val outputStream = try {
FileOutputStream(tempFile)
} catch (e: FileNotFoundException) {
return false
}
val decodeBitmapInputStream = try {
contentResolver.openInputStream(uri)
} catch (e: FileNotFoundException) {
return false
}
options.inSampleSize = calculateInSampleSize(options, scaledImageSize, scaledImageSize)
options.inJustDecodeBounds = false
val scaledBitmap: Bitmap = try {
BitmapFactory.decodeStream(decodeBitmapInputStream, null, options)
} catch (error: OutOfMemoryError) {
return false
} finally {
IOUtils.closeQuietly(decodeBitmapInputStream)
} ?: return false
val reorientedBitmap = reorientBitmap(scaledBitmap, orientation)
if (reorientedBitmap == null) {
scaledBitmap.recycle()
return false
}
/* Retain transparency if there is any by encoding as png */
val format: CompressFormat = if (!reorientedBitmap.hasAlpha()) {
CompressFormat.JPEG
} else {
CompressFormat.PNG
}
reorientedBitmap.compress(format, 85, outputStream)
reorientedBitmap.recycle()
scaledImageSize /= 2
} while (tempFile.length() > sizeLimit)
return true
}

View file

@ -26,15 +26,20 @@ import androidx.core.net.toUri
import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MediaUploadApi
import com.keylesspalace.tusky.network.ProgressRequestBody import com.keylesspalace.tusky.network.ProgressRequestBody
import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN
import com.keylesspalace.tusky.util.getImageSquarePixels import com.keylesspalace.tusky.util.getImageSquarePixels
import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.getMediaSize
import com.keylesspalace.tusky.util.randomAlphanumericString import com.keylesspalace.tusky.util.randomAlphanumericString
import io.reactivex.rxjava3.core.Observable import kotlinx.coroutines.Dispatchers
import io.reactivex.rxjava3.core.Single import kotlinx.coroutines.ExperimentalCoroutinesApi
import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import java.io.File import java.io.File
@ -70,63 +75,42 @@ class CouldNotOpenFileException : Exception()
class MediaUploader @Inject constructor( class MediaUploader @Inject constructor(
private val context: Context, private val context: Context,
private val mastodonApi: MastodonApi private val mediaUploadApi: MediaUploadApi
) { ) {
fun uploadMedia(media: QueuedMedia): Observable<UploadEvent> {
return Observable @OptIn(ExperimentalCoroutinesApi::class)
.fromCallable { fun uploadMedia(media: QueuedMedia): Flow<UploadEvent> {
if (shouldResizeMedia(media)) { return flow {
downsize(media) if (shouldResizeMedia(media)) {
} else media emit(downsize(media))
} else {
emit(media)
} }
.switchMap { upload(it) } }
.subscribeOn(Schedulers.io()) .flatMapLatest { upload(it) }
.flowOn(Dispatchers.IO)
} }
fun prepareMedia(inUri: Uri): Single<PreparedMedia> { fun prepareMedia(inUri: Uri): PreparedMedia {
return Single.fromCallable { var mediaSize = MEDIA_SIZE_UNKNOWN
var mediaSize = MEDIA_SIZE_UNKNOWN var uri = inUri
var uri = inUri val mimeType: String?
var mimeType: String? = null
try { try {
when (inUri.scheme) { when (inUri.scheme) {
ContentResolver.SCHEME_CONTENT -> { ContentResolver.SCHEME_CONTENT -> {
mimeType = contentResolver.getType(uri) mimeType = contentResolver.getType(uri)
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp") val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
contentResolver.openInputStream(inUri).use { input -> contentResolver.openInputStream(inUri).use { input ->
if (input == null) { if (input == null) {
Log.w(TAG, "Media input is null") Log.w(TAG, "Media input is null")
uri = inUri uri = inUri
return@use return@use
}
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
FileOutputStream(file.absoluteFile).use { out ->
input.copyTo(out)
uri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + ".fileprovider",
file
)
mediaSize = getMediaSize(contentResolver, uri)
}
} }
} val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
ContentResolver.SCHEME_FILE -> {
val path = uri.path
if (path == null) {
Log.w(TAG, "empty uri path $uri")
throw CouldNotOpenFileException()
}
val inputFile = File(path)
val suffix = inputFile.name.substringAfterLast('.', "tmp")
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix)
val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir)
val input = FileInputStream(inputFile)
FileOutputStream(file.absoluteFile).use { out -> FileOutputStream(file.absoluteFile).use { out ->
input.copyTo(out) input.copyTo(out)
uri = FileProvider.getUriForFile( uri = FileProvider.getUriForFile(
@ -137,53 +121,74 @@ class MediaUploader @Inject constructor(
mediaSize = getMediaSize(contentResolver, uri) mediaSize = getMediaSize(contentResolver, uri)
} }
} }
else -> { }
Log.w(TAG, "Unknown uri scheme $uri") ContentResolver.SCHEME_FILE -> {
val path = uri.path
if (path == null) {
Log.w(TAG, "empty uri path $uri")
throw CouldNotOpenFileException() throw CouldNotOpenFileException()
} }
} val inputFile = File(path)
} catch (e: IOException) { val suffix = inputFile.name.substringAfterLast('.', "tmp")
Log.w(TAG, e) mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix)
throw CouldNotOpenFileException() val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir)
} val input = FileInputStream(inputFile)
if (mediaSize == MEDIA_SIZE_UNKNOWN) {
Log.w(TAG, "Could not determine file size of upload")
throw MediaTypeException()
}
if (mimeType != null) { FileOutputStream(file.absoluteFile).use { out ->
val topLevelType = mimeType.substring(0, mimeType.indexOf('/')) input.copyTo(out)
when (topLevelType) { uri = FileProvider.getUriForFile(
"video" -> { context,
if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) { BuildConfig.APPLICATION_ID + ".fileprovider",
throw VideoSizeException() file
} )
PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize) mediaSize = getMediaSize(contentResolver, uri)
}
"image" -> {
PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
}
"audio" -> {
if (mediaSize > STATUS_AUDIO_SIZE_LIMIT) {
throw AudioSizeException()
}
PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize)
}
else -> {
throw MediaTypeException()
} }
} }
} else { else -> {
Log.w(TAG, "Could not determine mime type of upload") Log.w(TAG, "Unknown uri scheme $uri")
throw MediaTypeException() throw CouldNotOpenFileException()
}
} }
} catch (e: IOException) {
Log.w(TAG, e)
throw CouldNotOpenFileException()
}
if (mediaSize == MEDIA_SIZE_UNKNOWN) {
Log.w(TAG, "Could not determine file size of upload")
throw MediaTypeException()
}
if (mimeType != null) {
return when (mimeType.substring(0, mimeType.indexOf('/'))) {
"video" -> {
if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) {
throw VideoSizeException()
}
PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize)
}
"image" -> {
PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
}
"audio" -> {
if (mediaSize > STATUS_AUDIO_SIZE_LIMIT) {
throw AudioSizeException()
}
PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize)
}
else -> {
throw MediaTypeException()
}
}
} else {
Log.w(TAG, "Could not determine mime type of upload")
throw MediaTypeException()
} }
} }
private val contentResolver = context.contentResolver private val contentResolver = context.contentResolver
private fun upload(media: QueuedMedia): Observable<UploadEvent> { private suspend fun upload(media: QueuedMedia): Flow<UploadEvent> {
return Observable.create { emitter -> return callbackFlow {
var mimeType = contentResolver.getType(media.uri) var mimeType = contentResolver.getType(media.uri)
val map = MimeTypeMap.getSingleton() val map = MimeTypeMap.getSingleton()
val fileExtension = map.getExtensionFromMimeType(mimeType) val fileExtension = map.getExtensionFromMimeType(mimeType)
@ -200,11 +205,11 @@ class MediaUploader @Inject constructor(
var lastProgress = -1 var lastProgress = -1
val fileBody = ProgressRequestBody( val fileBody = ProgressRequestBody(
stream, media.mediaSize, stream!!, media.mediaSize,
mimeType.toMediaTypeOrNull() mimeType.toMediaTypeOrNull()!!
) { percentage -> ) { percentage ->
if (percentage != lastProgress) { if (percentage != lastProgress) {
emitter.onNext(UploadEvent.ProgressEvent(percentage)) trySend(UploadEvent.ProgressEvent(percentage))
} }
lastProgress = percentage lastProgress = percentage
} }
@ -217,28 +222,15 @@ class MediaUploader @Inject constructor(
null null
} }
val uploadDisposable = mastodonApi.uploadMedia(body, description) val result = mediaUploadApi.uploadMedia(body, description).getOrThrow()
.subscribe( send(UploadEvent.FinishedEvent(result.id))
{ result -> awaitClose()
emitter.onNext(UploadEvent.FinishedEvent(result.id))
emitter.onComplete()
},
{ e ->
emitter.onError(e)
}
)
// Cancel the request when our observable is cancelled
emitter.setDisposable(uploadDisposable)
} }
} }
private fun downsize(media: QueuedMedia): QueuedMedia { private fun downsize(media: QueuedMedia): QueuedMedia {
val file = createNewImageFile(context) val file = createNewImageFile(context)
DownsizeImageTask.resize( downsizeImage(media.uri, STATUS_IMAGE_SIZE_LIMIT, contentResolver, file)
arrayOf(media.uri),
STATUS_IMAGE_SIZE_LIMIT, context.contentResolver, file
)
return media.copy(uri = file.toUri(), mediaSize = file.length()) return media.copy(uri = file.toUri(), mediaSize = file.length())
} }

View file

@ -27,7 +27,7 @@ import android.widget.LinearLayout
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData import androidx.lifecycle.lifecycleScope
import at.connyduck.sparkbutton.helpers.Utils import at.connyduck.sparkbutton.helpers.Utils
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
@ -35,7 +35,7 @@ import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
import com.github.chrisbanes.photoview.PhotoView import com.github.chrisbanes.photoview.PhotoView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.util.withLifecycleContext import kotlinx.coroutines.launch
// https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32 // https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500 private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
@ -43,7 +43,7 @@ private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
fun <T> T.makeCaptionDialog( fun <T> T.makeCaptionDialog(
existingDescription: String?, existingDescription: String?,
previewUri: Uri, previewUri: Uri,
onUpdateDescription: (String) -> LiveData<Boolean> onUpdateDescription: suspend (String) -> Boolean
) where T : Activity, T : LifecycleOwner { ) where T : Activity, T : LifecycleOwner {
val dialogLayout = LinearLayout(this) val dialogLayout = LinearLayout(this)
val padding = Utils.dpToPx(this, 8) val padding = Utils.dpToPx(this, 8)
@ -77,12 +77,11 @@ fun <T> T.makeCaptionDialog(
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT)) input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
val okListener = { dialog: DialogInterface, _: Int -> val okListener = { dialog: DialogInterface, _: Int ->
onUpdateDescription(input.text.toString()) lifecycleScope.launch {
withLifecycleContext { if (!onUpdateDescription(input.text.toString())) {
onUpdateDescription(input.text.toString()) showFailedCaptionMessage()
.observe { success -> if (!success) showFailedCaptionMessage() } }
} }
dialog.dismiss() dialog.dismiss()
} }

View file

@ -26,7 +26,7 @@ import androidx.core.view.OnReceiveContentListener
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.emoji.widget.EmojiEditTextHelper import androidx.emoji2.viewsintegration.EmojiEditTextHelper
class EditTextTyped @JvmOverloads constructor( class EditTextTyped @JvmOverloads constructor(
context: Context, context: Context,

View file

@ -26,7 +26,7 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
class ConversationAdapter( class ConversationAdapter(
private val statusDisplayOptions: StatusDisplayOptions, private val statusDisplayOptions: StatusDisplayOptions,
private val listener: StatusActionListener private val listener: StatusActionListener
) : PagingDataAdapter<ConversationEntity, ConversationViewHolder>(CONVERSATION_COMPARATOR) { ) : PagingDataAdapter<ConversationViewData, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false) val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
@ -37,17 +37,13 @@ class ConversationAdapter(
holder.setupWithConversation(getItem(position)) holder.setupWithConversation(getItem(position))
} }
fun item(position: Int): ConversationEntity? {
return getItem(position)
}
companion object { companion object {
val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationEntity>() { val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationViewData>() {
override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean { override fun areItemsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
return oldItem.id == newItem.id return oldItem.id == newItem.id
} }
override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean { override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
return oldItem == newItem return oldItem == newItem
} }
} }

View file

@ -15,7 +15,6 @@
package com.keylesspalace.tusky.components.conversation package com.keylesspalace.tusky.components.conversation
import android.text.Spanned
import androidx.room.Embedded import androidx.room.Embedded
import androidx.room.Entity import androidx.room.Entity
import androidx.room.TypeConverters import androidx.room.TypeConverters
@ -27,7 +26,7 @@ import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.shouldTrimStatus import com.keylesspalace.tusky.viewdata.StatusViewData
import java.util.Date import java.util.Date
@Entity(primaryKeys = ["id", "accountId"]) @Entity(primaryKeys = ["id", "accountId"])
@ -38,7 +37,16 @@ data class ConversationEntity(
val accounts: List<ConversationAccountEntity>, val accounts: List<ConversationAccountEntity>,
val unread: Boolean, val unread: Boolean,
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
) ) {
fun toViewData(): ConversationViewData {
return ConversationViewData(
id = id,
accounts = accounts,
unread = unread,
lastStatus = lastStatus.toViewData()
)
}
}
data class ConversationAccountEntity( data class ConversationAccountEntity(
val id: String, val id: String,
@ -67,7 +75,7 @@ data class ConversationStatusEntity(
val inReplyToId: String?, val inReplyToId: String?,
val inReplyToAccountId: String?, val inReplyToAccountId: String?,
val account: ConversationAccountEntity, val account: ConversationAccountEntity,
val content: Spanned, val content: String,
val createdAt: Date, val createdAt: Date,
val emojis: List<Emoji>, val emojis: List<Emoji>,
val favouritesCount: Int, val favouritesCount: Int,
@ -80,95 +88,43 @@ data class ConversationStatusEntity(
val tags: List<HashTag>?, val tags: List<HashTag>?,
val showingHiddenContent: Boolean, val showingHiddenContent: Boolean,
val expanded: Boolean, val expanded: Boolean,
val collapsible: Boolean,
val collapsed: Boolean, val collapsed: Boolean,
val muted: Boolean, val muted: Boolean,
val poll: Poll? val poll: Poll?
) { ) {
/** its necessary to override this because Spanned.equals does not work as expected */
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ConversationStatusEntity fun toViewData(): StatusViewData.Concrete {
return StatusViewData.Concrete(
if (id != other.id) return false status = Status(
if (url != other.url) return false id = id,
if (inReplyToId != other.inReplyToId) return false url = url,
if (inReplyToAccountId != other.inReplyToAccountId) return false account = account.toAccount(),
if (account != other.account) return false inReplyToId = inReplyToId,
if (content.toString() != other.content.toString()) return false inReplyToAccountId = inReplyToAccountId,
if (createdAt != other.createdAt) return false content = content,
if (emojis != other.emojis) return false reblog = null,
if (favouritesCount != other.favouritesCount) return false createdAt = createdAt,
if (favourited != other.favourited) return false emojis = emojis,
if (sensitive != other.sensitive) return false reblogsCount = 0,
if (spoilerText != other.spoilerText) return false favouritesCount = favouritesCount,
if (attachments != other.attachments) return false reblogged = false,
if (mentions != other.mentions) return false favourited = favourited,
if (tags != other.tags) return false bookmarked = bookmarked,
if (showingHiddenContent != other.showingHiddenContent) return false sensitive = sensitive,
if (expanded != other.expanded) return false spoilerText = spoilerText,
if (collapsible != other.collapsible) return false visibility = Status.Visibility.DIRECT,
if (collapsed != other.collapsed) return false attachments = attachments,
if (muted != other.muted) return false mentions = mentions,
if (poll != other.poll) return false tags = tags,
application = null,
return true pinned = false,
} muted = muted,
poll = poll,
override fun hashCode(): Int { card = null
var result = id.hashCode() ),
result = 31 * result + (url?.hashCode() ?: 0) isExpanded = expanded,
result = 31 * result + (inReplyToId?.hashCode() ?: 0) isShowingContent = showingHiddenContent,
result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0) isCollapsed = collapsed
result = 31 * result + account.hashCode()
result = 31 * result + content.toString().hashCode()
result = 31 * result + createdAt.hashCode()
result = 31 * result + emojis.hashCode()
result = 31 * result + favouritesCount
result = 31 * result + favourited.hashCode()
result = 31 * result + sensitive.hashCode()
result = 31 * result + spoilerText.hashCode()
result = 31 * result + attachments.hashCode()
result = 31 * result + mentions.hashCode()
result = 31 * result + tags.hashCode()
result = 31 * result + showingHiddenContent.hashCode()
result = 31 * result + expanded.hashCode()
result = 31 * result + collapsible.hashCode()
result = 31 * result + collapsed.hashCode()
result = 31 * result + muted.hashCode()
result = 31 * result + poll.hashCode()
return result
}
fun toStatus(): Status {
return Status(
id = id,
url = url,
account = account.toAccount(),
inReplyToId = inReplyToId,
inReplyToAccountId = inReplyToAccountId,
content = content,
reblog = null,
createdAt = createdAt,
emojis = emojis,
reblogsCount = 0,
favouritesCount = favouritesCount,
reblogged = false,
favourited = favourited,
bookmarked = bookmarked,
sensitive = sensitive,
spoilerText = spoilerText,
visibility = Status.Visibility.DIRECT,
attachments = attachments,
mentions = mentions,
tags = tags,
application = null,
pinned = false,
muted = muted,
poll = poll,
card = null
) )
} }
} }
@ -202,7 +158,6 @@ fun Status.toEntity() =
tags = tags, tags = tags,
showingHiddenContent = false, showingHiddenContent = false,
expanded = false, expanded = false,
collapsible = shouldTrimStatus(content),
collapsed = true, collapsed = true,
muted = muted ?: false, muted = muted ?: false,
poll = poll poll = poll

View file

@ -0,0 +1,87 @@
/* Copyright 2022 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.conversation
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.viewdata.StatusViewData
data class ConversationViewData(
val id: String,
val accounts: List<ConversationAccountEntity>,
val unread: Boolean,
val lastStatus: StatusViewData.Concrete
) {
fun toEntity(
accountId: Long,
favourited: Boolean = lastStatus.status.favourited,
bookmarked: Boolean = lastStatus.status.bookmarked,
muted: Boolean = lastStatus.status.muted ?: false,
poll: Poll? = lastStatus.status.poll,
expanded: Boolean = lastStatus.isExpanded,
collapsed: Boolean = lastStatus.isCollapsed,
showingHiddenContent: Boolean = lastStatus.isShowingContent
): ConversationEntity {
return ConversationEntity(
accountId = accountId,
id = id,
accounts = accounts,
unread = unread,
lastStatus = lastStatus.toConversationStatusEntity(
favourited = favourited,
bookmarked = bookmarked,
muted = muted,
poll = poll,
expanded = expanded,
collapsed = collapsed,
showingHiddenContent = showingHiddenContent
)
)
}
}
fun StatusViewData.Concrete.toConversationStatusEntity(
favourited: Boolean = status.favourited,
bookmarked: Boolean = status.bookmarked,
muted: Boolean = status.muted ?: false,
poll: Poll? = status.poll,
expanded: Boolean = isExpanded,
collapsed: Boolean = isCollapsed,
showingHiddenContent: Boolean = isShowingContent
): ConversationStatusEntity {
return ConversationStatusEntity(
id = id,
url = status.url,
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
account = status.account.toEntity(),
content = status.content,
createdAt = status.createdAt,
emojis = status.emojis,
favouritesCount = status.favouritesCount,
favourited = favourited,
bookmarked = bookmarked,
sensitive = status.sensitive,
spoilerText = status.spoilerText,
attachments = status.attachments,
mentions = status.mentions,
tags = status.tags,
showingHiddenContent = showingHiddenContent,
expanded = expanded,
collapsed = collapsed,
muted = muted,
poll = poll
)
}

View file

@ -28,11 +28,14 @@ import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; import com.keylesspalace.tusky.adapter.StatusBaseViewHolder;
import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper;
import com.keylesspalace.tusky.util.SmartLengthInputFilter; import com.keylesspalace.tusky.util.SmartLengthInputFilter;
import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.viewdata.PollViewDataKt; import com.keylesspalace.tusky.viewdata.PollViewDataKt;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.List; import java.util.List;
@ -69,11 +72,12 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height); return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height);
} }
void setupWithConversation(ConversationEntity conversation) { void setupWithConversation(ConversationViewData conversation) {
ConversationStatusEntity status = conversation.getLastStatus(); StatusViewData.Concrete statusViewData = conversation.getLastStatus();
ConversationAccountEntity account = status.getAccount(); Status status = statusViewData.getStatus();
TimelineAccount account = status.getAccount();
setupCollapsedState(status.getCollapsible(), status.getCollapsed(), status.getExpanded(), status.getSpoilerText(), listener); setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions); setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
setUsername(account.getUsername()); setUsername(account.getUsername());
@ -84,7 +88,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
List<Attachment> attachments = status.getAttachments(); List<Attachment> attachments = status.getAttachments();
boolean sensitive = status.getSensitive(); boolean sensitive = status.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent(), setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
statusDisplayOptions.useBlurhash()); statusDisplayOptions.useBlurhash());
if (attachments.size() == 0) { if (attachments.size() == 0) {
@ -95,7 +99,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
mediaLabel.setVisibility(View.GONE); mediaLabel.setVisibility(View.GONE);
} }
} else { } else {
setMediaLabel(attachments, sensitive, listener, status.getShowingHiddenContent()); setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
// Hide all unused views. // Hide all unused views.
mediaPreviews[0].setVisibility(View.GONE); mediaPreviews[0].setVisibility(View.GONE);
mediaPreviews[1].setVisibility(View.GONE); mediaPreviews[1].setVisibility(View.GONE);
@ -104,10 +108,10 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
hideSensitiveMediaWarning(); hideSensitiveMediaWarning();
} }
setupButtons(listener, account.getId(), status.getContent().toString(), setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
statusDisplayOptions); statusDisplayOptions);
setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(), setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
status.getMentions(), status.getTags(), status.getEmojis(), status.getMentions(), status.getTags(), status.getEmojis(),
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener); PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);

View file

@ -153,24 +153,24 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onFavourite(favourite: Boolean, position: Int) { override fun onFavourite(favourite: Boolean, position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewModel.favourite(favourite, conversation) viewModel.favourite(favourite, conversation)
} }
} }
override fun onBookmark(favourite: Boolean, position: Int) { override fun onBookmark(favourite: Boolean, position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewModel.bookmark(favourite, conversation) viewModel.bookmark(favourite, conversation)
} }
} }
override fun onMore(view: View, position: Int) { override fun onMore(view: View, position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
val popup = PopupMenu(requireContext(), view) val popup = PopupMenu(requireContext(), view)
popup.inflate(R.menu.conversation_more) popup.inflate(R.menu.conversation_more)
if (conversation.lastStatus.muted) { if (conversation.lastStatus.status.muted == true) {
popup.menu.removeItem(R.id.status_mute_conversation) popup.menu.removeItem(R.id.status_mute_conversation)
} else { } else {
popup.menu.removeItem(R.id.status_unmute_conversation) popup.menu.removeItem(R.id.status_unmute_conversation)
@ -189,14 +189,14 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.toStatus()), view) viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.status), view)
} }
} }
override fun onViewThread(position: Int) { override fun onViewThread(position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewThread(conversation.lastStatus.id, conversation.lastStatus.url) viewThread(conversation.lastStatus.id, conversation.lastStatus.status.url)
} }
} }
@ -205,13 +205,13 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onExpandedChange(expanded: Boolean, position: Int) { override fun onExpandedChange(expanded: Boolean, position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewModel.expandHiddenStatus(expanded, conversation) viewModel.expandHiddenStatus(expanded, conversation)
} }
} }
override fun onContentHiddenChange(isShowing: Boolean, position: Int) { override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewModel.showContent(isShowing, conversation) viewModel.showContent(isShowing, conversation)
} }
} }
@ -221,7 +221,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewModel.collapseLongStatus(isCollapsed, conversation) viewModel.collapseLongStatus(isCollapsed, conversation)
} }
} }
@ -241,12 +241,12 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onReply(position: Int) { override fun onReply(position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
reply(conversation.lastStatus.toStatus()) reply(conversation.lastStatus.status)
} }
} }
private fun deleteConversation(conversation: ConversationEntity) { private fun deleteConversation(conversation: ConversationViewData) {
AlertDialog.Builder(requireContext()) AlertDialog.Builder(requireContext())
.setMessage(R.string.dialog_delete_conversation_warning) .setMessage(R.string.dialog_delete_conversation_warning)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
@ -268,7 +268,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) { override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewModel.voteInPoll(choices, conversation) viewModel.voteInPoll(choices, conversation)
} }
} }

View file

@ -16,16 +16,18 @@
package com.keylesspalace.tusky.components.conversation package com.keylesspalace.tusky.components.conversation
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.ExperimentalPagingApi import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.map
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.RxAwareViewModel import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
import javax.inject.Inject import javax.inject.Inject
@ -35,7 +37,7 @@ class ConversationsViewModel @Inject constructor(
private val database: AppDatabase, private val database: AppDatabase,
private val accountManager: AccountManager, private val accountManager: AccountManager,
private val api: MastodonApi private val api: MastodonApi
) : RxAwareViewModel() { ) : ViewModel() {
@OptIn(ExperimentalPagingApi::class) @OptIn(ExperimentalPagingApi::class)
val conversationFlow = Pager( val conversationFlow = Pager(
@ -44,104 +46,117 @@ class ConversationsViewModel @Inject constructor(
pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) } pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) }
) )
.flow .flow
.map { pagingData ->
pagingData.map { conversation -> conversation.toViewData() }
}
.cachedIn(viewModelScope) .cachedIn(viewModelScope)
fun favourite(favourite: Boolean, conversation: ConversationEntity) { fun favourite(favourite: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
try { try {
timelineCases.favourite(conversation.lastStatus.id, favourite).await() timelineCases.favourite(conversation.lastStatus.id, favourite).await()
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = conversation.lastStatus.copy(favourited = favourite) accountId = accountManager.activeAccount!!.id,
favourited = favourite
) )
database.conversationDao().insert(newConversation) saveConversationToDb(newConversation)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "failed to favourite status", e) Log.w(TAG, "failed to favourite status", e)
} }
} }
} }
fun bookmark(bookmark: Boolean, conversation: ConversationEntity) { fun bookmark(bookmark: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
try { try {
timelineCases.bookmark(conversation.lastStatus.id, bookmark).await() timelineCases.bookmark(conversation.lastStatus.id, bookmark).await()
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = conversation.lastStatus.copy(bookmarked = bookmark) accountId = accountManager.activeAccount!!.id,
bookmarked = bookmark
) )
database.conversationDao().insert(newConversation) saveConversationToDb(newConversation)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "failed to bookmark status", e) Log.w(TAG, "failed to bookmark status", e)
} }
} }
} }
fun voteInPoll(choices: List<Int>, conversation: ConversationEntity) { fun voteInPoll(choices: List<Int>, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
try { try {
val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.poll?.id!!, choices).await() val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices).await()
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = conversation.lastStatus.copy(poll = poll) accountId = accountManager.activeAccount!!.id,
poll = poll
) )
database.conversationDao().insert(newConversation) saveConversationToDb(newConversation)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "failed to vote in poll", e) Log.w(TAG, "failed to vote in poll", e)
} }
} }
} }
fun expandHiddenStatus(expanded: Boolean, conversation: ConversationEntity) { fun expandHiddenStatus(expanded: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = conversation.lastStatus.copy(expanded = expanded) accountId = accountManager.activeAccount!!.id,
expanded = expanded
) )
saveConversationToDb(newConversation) saveConversationToDb(newConversation)
} }
} }
fun collapseLongStatus(collapsed: Boolean, conversation: ConversationEntity) { fun collapseLongStatus(collapsed: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = conversation.lastStatus.copy(collapsed = collapsed) accountId = accountManager.activeAccount!!.id,
collapsed = collapsed
) )
saveConversationToDb(newConversation) saveConversationToDb(newConversation)
} }
} }
fun showContent(showing: Boolean, conversation: ConversationEntity) { fun showContent(showing: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing) accountId = accountManager.activeAccount!!.id,
showingHiddenContent = showing
) )
saveConversationToDb(newConversation) saveConversationToDb(newConversation)
} }
} }
fun remove(conversation: ConversationEntity) { fun remove(conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
try { try {
api.deleteConversation(conversationId = conversation.id) api.deleteConversation(conversationId = conversation.id)
database.conversationDao().delete(conversation) database.conversationDao().delete(
id = conversation.id,
accountId = accountManager.activeAccount!!.id
)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "failed to delete conversation", e) Log.w(TAG, "failed to delete conversation", e)
} }
} }
} }
fun muteConversation(conversation: ConversationEntity) { fun muteConversation(conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
try { try {
val newStatus = timelineCases.muteConversation( timelineCases.muteConversation(
conversation.lastStatus.id, conversation.lastStatus.id,
!conversation.lastStatus.muted !(conversation.lastStatus.status.muted ?: false)
).await() ).await()
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = newStatus.toEntity() accountId = accountManager.activeAccount!!.id,
muted = !(conversation.lastStatus.status.muted ?: false)
) )
database.conversationDao().insert(newConversation) database.conversationDao().insert(newConversation)
@ -151,7 +166,7 @@ class ConversationsViewModel @Inject constructor(
} }
} }
suspend fun saveConversationToDb(conversation: ConversationEntity) { private suspend fun saveConversationToDb(conversation: ConversationEntity) {
database.conversationDao().insert(conversation) database.conversationDao().insert(conversation)
} }

View file

@ -30,7 +30,12 @@ import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.IOUtils import com.keylesspalace.tusky.util.IOUtils
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.buffer
import okio.sink
import java.io.File import java.io.File
import java.io.IOException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@ -38,6 +43,7 @@ import javax.inject.Inject
class DraftHelper @Inject constructor( class DraftHelper @Inject constructor(
val context: Context, val context: Context,
val okHttpClient: OkHttpClient,
db: AppDatabase db: AppDatabase
) { ) {
@ -71,11 +77,11 @@ class DraftHelper @Inject constructor(
val uris = mediaUris.map { uriString -> val uris = mediaUris.map { uriString ->
uriString.toUri() uriString.toUri()
}.map { uri -> }.mapNotNull { uri ->
if (uri.isNotInFolder(draftDirectory)) { if (uri.isInFolder(draftDirectory)) {
uri.copyToFolder(draftDirectory)
} else {
uri uri
} else {
uri.copyToFolder(draftDirectory)
} }
} }
@ -114,6 +120,7 @@ class DraftHelper @Inject constructor(
) )
draftDao.insertOrReplace(draft) draftDao.insertOrReplace(draft)
Log.d("DraftHelper", "saved draft to db")
} }
suspend fun deleteDraftAndAttachments(draftId: Int) { suspend fun deleteDraftAndAttachments(draftId: Int) {
@ -133,33 +140,55 @@ class DraftHelper @Inject constructor(
} }
} }
suspend fun deleteAttachments(draft: DraftEntity) { suspend fun deleteAttachments(draft: DraftEntity) = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { draft.attachments.forEach { attachment ->
draft.attachments.forEach { attachment -> if (context.contentResolver.delete(attachment.uri, null, null) == 0) {
if (context.contentResolver.delete(attachment.uri, null, null) == 0) { Log.e("DraftHelper", "Did not delete file ${attachment.uriString}")
Log.e("DraftHelper", "Did not delete file ${attachment.uriString}")
}
} }
} }
} }
private fun Uri.isNotInFolder(folder: File): Boolean { private fun Uri.isInFolder(folder: File): Boolean {
val filePath = path ?: return true val filePath = path ?: return true
return File(filePath).parentFile == folder return File(filePath).parentFile == folder
} }
private fun Uri.copyToFolder(folder: File): Uri { private fun Uri.copyToFolder(folder: File): Uri? {
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val mimeType = contentResolver.getType(this) val fileExtension = if (scheme == "https") {
val map = MimeTypeMap.getSingleton() lastPathSegment?.substringAfterLast('.', "tmp")
val fileExtension = map.getExtensionFromMimeType(mimeType) } else {
val mimeType = contentResolver.getType(this)
val map = MimeTypeMap.getSingleton()
map.getExtensionFromMimeType(mimeType)
}
val filename = String.format("Tusky_Draft_Media_%s.%s", timeStamp, fileExtension) val filename = String.format("Tusky_Draft_Media_%s.%s", timeStamp, fileExtension)
val file = File(folder, filename) val file = File(folder, filename)
IOUtils.copyToFile(contentResolver, this, file)
if (scheme == "https") {
// saving redrafted media
try {
val request = Request.Builder().url(toString()).build()
val response = okHttpClient.newCall(request).execute()
val sink = file.sink().buffer()
response.body?.source()?.use { input ->
sink.use { output ->
output.writeAll(input)
}
}
} catch (ex: IOException) {
Log.w("DraftHelper", "failed to save media", ex)
return null
}
} else {
IOUtils.copyToFile(contentResolver, this, file)
}
return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file) return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file)
} }
} }

View file

@ -35,6 +35,7 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.databinding.ActivityDraftsBinding import com.keylesspalace.tusky.databinding.ActivityDraftsBinding
import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@ -100,7 +101,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
content = draft.content, content = draft.content,
contentWarning = draft.contentWarning, contentWarning = draft.contentWarning,
inReplyToId = draft.inReplyToId, inReplyToId = draft.inReplyToId,
replyingStatusContent = status.content.toString(), replyingStatusContent = status.content.parseAsMastodonHtml().toString(),
replyingStatusAuthor = status.account.localUsername, replyingStatusAuthor = status.account.localUsername,
draftAttachments = draft.attachments, draftAttachments = draft.attachments,
poll = draft.poll, poll = draft.poll,

View file

@ -0,0 +1,25 @@
/* Copyright 2022 Tusky contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.instanceinfo
data class InstanceInfo(
val maxChars: Int,
val pollMaxOptions: Int,
val pollMaxLength: Int,
val pollMinDuration: Int,
val pollMaxDuration: Int,
val charactersReservedPerUrl: Int
)

View file

@ -0,0 +1,102 @@
/* Copyright 2022 Tusky contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.instanceinfo
import android.util.Log
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.EmojisEntity
import com.keylesspalace.tusky.db.InstanceInfoEntity
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
class InstanceInfoRepository @Inject constructor(
private val api: MastodonApi,
db: AppDatabase,
accountManager: AccountManager
) {
private val dao = db.instanceDao()
private val instanceName = accountManager.activeAccount!!.domain
/**
* Returns the custom emojis of the instance.
* Will always try to fetch them from the api, falls back to cached Emojis in case it is not available.
* Never throws, returns empty list in case of error.
*/
suspend fun getEmojis(): List<Emoji> = withContext(Dispatchers.IO) {
api.getCustomEmojis()
.onSuccess { emojiList -> dao.insertOrReplace(EmojisEntity(instanceName, emojiList)) }
.getOrElse { throwable ->
Log.w(TAG, "failed to load custom emojis, falling back to cache", throwable)
dao.getEmojiInfo(instanceName)?.emojiList.orEmpty()
}
}
/**
* Returns information about the instance.
* Will always try to fetch the most up-to-date data from the api, falls back to cache in case it is not available.
* Never throws, returns defaults of vanilla Mastodon in case of error.
*/
suspend fun getInstanceInfo(): InstanceInfo = withContext(Dispatchers.IO) {
api.getInstance()
.fold(
{ instance ->
val instanceEntity = InstanceInfoEntity(
instance = instanceName,
maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars,
maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions,
maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars,
minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration,
maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration,
charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl,
version = instance.version
)
dao.insertOrReplace(instanceEntity)
instanceEntity
},
{ throwable ->
Log.w(TAG, "failed to instance, falling back to cache and default values", throwable)
dao.getInstanceInfo(instanceName)
}
).let { instanceInfo: InstanceInfoEntity? ->
InstanceInfo(
maxChars = instanceInfo?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
pollMaxOptions = instanceInfo?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
pollMaxLength = instanceInfo?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
pollMinDuration = instanceInfo?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION,
pollMaxDuration = instanceInfo?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION,
charactersReservedPerUrl = instanceInfo?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL
)
}
}
companion object {
private const val TAG = "InstanceInfoRepo"
const val DEFAULT_CHARACTER_LIMIT = 500
private const val DEFAULT_MAX_OPTION_COUNT = 4
private const val DEFAULT_MAX_OPTION_LENGTH = 50
private const val DEFAULT_MIN_POLL_DURATION = 300
private const val DEFAULT_MAX_POLL_DURATION = 604800
// Mastodon only counts URLs as this long in terms of status character limits
const val DEFAULT_CHARACTERS_RESERVED_PER_URL = 23
}
}

View file

@ -34,7 +34,6 @@ import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityLoginBinding import com.keylesspalace.tusky.databinding.ActivityLoginBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.AppCredentials
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.rickRoll import com.keylesspalace.tusky.util.rickRoll
@ -70,7 +69,9 @@ class LoginActivity : BaseActivity(), Injectable {
// Authorization failed. Put the error response where the user can read it and they // Authorization failed. Put the error response where the user can read it and they
// can try again. // can try again.
setLoading(false) setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_authorization_denied) // Use error returned by the server or fall back to the generic message
binding.domainTextInputLayout.error =
result.errorMessage.ifBlank { getString(R.string.error_authorization_denied) }
Log.e( Log.e(
TAG, TAG,
"%s %s".format( "%s %s".format(
@ -180,32 +181,33 @@ class LoginActivity : BaseActivity(), Injectable {
setLoading(true) setLoading(true)
lifecycleScope.launch { lifecycleScope.launch {
val credentials: AppCredentials = try { mastodonApi.authenticateApp(
mastodonApi.authenticateApp( domain, getString(R.string.app_name), oauthRedirectUri,
domain, getString(R.string.app_name), oauthRedirectUri, OAUTH_SCOPES, getString(R.string.tusky_website)
OAUTH_SCOPES, getString(R.string.tusky_website) ).fold(
) { credentials ->
} catch (e: Exception) { // Before we open browser page we save the data.
binding.loginButton.isEnabled = true // Even if we don't open other apps user may go to password manager or somewhere else
binding.domainTextInputLayout.error = // and we will need to pick up the process where we left off.
getString(R.string.error_failed_app_registration) // Alternatively we could pass it all as part of the intent and receive it back
setLoading(false) // but it is a bit of a workaround.
Log.e(TAG, Log.getStackTraceString(e)) preferences.edit()
return@launch .putString(DOMAIN, domain)
} .putString(CLIENT_ID, credentials.clientId)
.putString(CLIENT_SECRET, credentials.clientSecret)
.apply()
// Before we open browser page we save the data. redirectUserToAuthorizeAndLogin(domain, credentials.clientId)
// Even if we don't open other apps user may go to password manager or somewhere else },
// and we will need to pick up the process where we left off. { e ->
// Alternatively we could pass it all as part of the intent and receive it back binding.loginButton.isEnabled = true
// but it is a bit of a workaround. binding.domainTextInputLayout.error =
preferences.edit() getString(R.string.error_failed_app_registration)
.putString(DOMAIN, domain) setLoading(false)
.putString(CLIENT_ID, credentials.clientId) Log.e(TAG, Log.getStackTraceString(e))
.putString(CLIENT_SECRET, credentials.clientSecret) return@launch
.apply() }
)
redirectUserToAuthorizeAndLogin(domain, credentials.clientId)
} }
} }
@ -238,29 +240,28 @@ class LoginActivity : BaseActivity(), Injectable {
setLoading(true) setLoading(true)
val accessToken = try { mastodonApi.fetchOAuthToken(
mastodonApi.fetchOAuthToken( domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code"
domain, clientId, clientSecret, oauthRedirectUri, code, ).fold(
"authorization_code" { accessToken ->
) accountManager.addAccount(accessToken.accessToken, domain)
} catch (e: Exception) {
setLoading(false)
binding.domainTextInputLayout.error =
getString(R.string.error_retrieving_oauth_token)
Log.e(
TAG,
"%s %s".format(getString(R.string.error_retrieving_oauth_token), e.message),
)
return
}
accountManager.addAccount(accessToken.accessToken, domain) val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
val intent = Intent(this, MainActivity::class.java) startActivity(intent)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK finish()
startActivity(intent) overridePendingTransition(R.anim.explode, R.anim.explode)
finish() },
overridePendingTransition(R.anim.explode, R.anim.explode) { e ->
setLoading(false)
binding.domainTextInputLayout.error =
getString(R.string.error_retrieving_oauth_token)
Log.e(
TAG,
"%s %s".format(getString(R.string.error_retrieving_oauth_token), e.message),
)
}
)
} }
private fun setLoading(loadingState: Boolean) { private fun setLoading(loadingState: Boolean) {

View file

@ -16,10 +16,13 @@ import android.webkit.WebStorage
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.core.net.toUri
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.databinding.LoginWebviewBinding import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityLoginWebviewBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -75,7 +78,7 @@ sealed class LoginResult : Parcelable {
/** Activity to do Oauth process using WebView. */ /** Activity to do Oauth process using WebView. */
class LoginWebViewActivity : BaseActivity(), Injectable { class LoginWebViewActivity : BaseActivity(), Injectable {
private val binding by viewBinding(LoginWebviewBinding::inflate) private val binding by viewBinding(ActivityLoginWebviewBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -86,7 +89,9 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
setSupportActionBar(binding.loginToolbar) setSupportActionBar(binding.loginToolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowTitleEnabled(false) supportActionBar?.setDisplayShowTitleEnabled(true)
setTitle(R.string.title_login)
val webView = binding.loginWebView val webView = binding.loginWebView
webView.settings.allowContentAccess = false webView.settings.allowContentAccess = false
@ -102,20 +107,34 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
val oauthUrl = data.oauthRedirectUrl val oauthUrl = data.oauthRedirectUrl
webView.webViewClient = object : WebViewClient() { webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
binding.loginProgress.hide()
}
override fun onReceivedError( override fun onReceivedError(
view: WebView?, view: WebView,
request: WebResourceRequest?, request: WebResourceRequest,
error: WebResourceError error: WebResourceError
) { ) {
Log.d("LoginWeb", "Failed to load ${data.url}: $error") Log.d("LoginWeb", "Failed to load ${data.url}: $error")
finish() sendResult(LoginResult.Err(getString(R.string.error_could_not_load_login_page)))
} }
override fun shouldOverrideUrlLoading( override fun shouldOverrideUrlLoading(
view: WebView, view: WebView,
request: WebResourceRequest request: WebResourceRequest
): Boolean { ): Boolean {
val url = request.url return shouldOverrideUrlLoading(request.url)
}
/* overriding this deprecated method is necessary for it to work on api levels < 24 */
@Suppress("OVERRIDE_DEPRECATION")
override fun shouldOverrideUrlLoading(view: WebView?, urlString: String?): Boolean {
val url = urlString?.toUri() ?: return false
return shouldOverrideUrlLoading(url)
}
fun shouldOverrideUrlLoading(url: Uri): Boolean {
return if (url.scheme == oauthUrl.scheme && url.host == oauthUrl.host) { return if (url.scheme == oauthUrl.scheme && url.host == oauthUrl.host) {
val error = url.getQueryParameter("error") val error = url.getQueryParameter("error")
if (error != null) { if (error != null) {
@ -130,6 +149,7 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
} }
} }
} }
webView.setBackgroundColor(Color.TRANSPARENT) webView.setBackgroundColor(Color.TRANSPARENT)
if (savedInstanceState == null) { if (savedInstanceState == null) {
@ -153,10 +173,14 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
super.onDestroy() super.onDestroy()
} }
override fun finish() {
super.finishWithoutSlideOutAnimation()
}
override fun requiresLogin() = false override fun requiresLogin() = false
private fun sendResult(result: LoginResult) { private fun sendResult(result: LoginResult) {
setResult(Activity.RESULT_OK, OauthLogin.makeResultIntent(result)) setResult(Activity.RESULT_OK, OauthLogin.makeResultIntent(result))
finish() finishWithoutSlideOutAnimation()
} }
} }

View file

@ -16,6 +16,9 @@
package com.keylesspalace.tusky.components.notifications; package com.keylesspalace.tusky.components.notifications;
import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml;
import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription;
import android.app.NotificationChannel; import android.app.NotificationChannel;
import android.app.NotificationChannelGroup; import android.app.NotificationChannelGroup;
import android.app.NotificationManager; import android.app.NotificationManager;
@ -73,8 +76,6 @@ import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.schedulers.Schedulers;
import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription;
public class NotificationHelper { public class NotificationHelper {
private static int notificationId = 0; private static int notificationId = 0;
@ -116,6 +117,8 @@ public class NotificationHelper {
public static final String CHANNEL_FAVOURITE = "CHANNEL_FAVOURITE"; public static final String CHANNEL_FAVOURITE = "CHANNEL_FAVOURITE";
public static final String CHANNEL_POLL = "CHANNEL_POLL"; public static final String CHANNEL_POLL = "CHANNEL_POLL";
public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS"; public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS";
public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP";
public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES";
/** /**
* WorkManager Tag * WorkManager Tag
@ -340,7 +343,7 @@ public class NotificationHelper {
Status status = body.getStatus(); Status status = body.getStatus();
String citedLocalAuthor = status.getAccount().getLocalUsername(); String citedLocalAuthor = status.getAccount().getLocalUsername();
String citedText = status.getContent().toString(); String citedText = parseAsMastodonHtml(status.getContent()).toString();
String inReplyToId = status.getId(); String inReplyToId = status.getId();
Status actionableStatus = status.getActionableStatus(); Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility(); Status.Visibility replyVisibility = actionableStatus.getVisibility();
@ -392,6 +395,8 @@ public class NotificationHelper {
CHANNEL_FAVOURITE + account.getIdentifier(), CHANNEL_FAVOURITE + account.getIdentifier(),
CHANNEL_POLL + account.getIdentifier(), CHANNEL_POLL + account.getIdentifier(),
CHANNEL_SUBSCRIPTIONS + account.getIdentifier(), CHANNEL_SUBSCRIPTIONS + account.getIdentifier(),
CHANNEL_SIGN_UP + account.getIdentifier(),
CHANNEL_UPDATES + account.getIdentifier(),
}; };
int[] channelNames = { int[] channelNames = {
R.string.notification_mention_name, R.string.notification_mention_name,
@ -401,6 +406,8 @@ public class NotificationHelper {
R.string.notification_favourite_name, R.string.notification_favourite_name,
R.string.notification_poll_name, R.string.notification_poll_name,
R.string.notification_subscription_name, R.string.notification_subscription_name,
R.string.notification_sign_up_name,
R.string.notification_update_name,
}; };
int[] channelDescriptions = { int[] channelDescriptions = {
R.string.notification_mention_descriptions, R.string.notification_mention_descriptions,
@ -410,6 +417,8 @@ public class NotificationHelper {
R.string.notification_favourite_description, R.string.notification_favourite_description,
R.string.notification_poll_description, R.string.notification_poll_description,
R.string.notification_subscription_description, R.string.notification_subscription_description,
R.string.notification_sign_up_description,
R.string.notification_update_description,
}; };
List<NotificationChannel> channels = new ArrayList<>(6); List<NotificationChannel> channels = new ArrayList<>(6);
@ -560,6 +569,10 @@ public class NotificationHelper {
return account.getNotificationsFavorited(); return account.getNotificationsFavorited();
case POLL: case POLL:
return account.getNotificationsPolls(); return account.getNotificationsPolls();
case SIGN_UP:
return account.getNotificationsSignUps();
case UPDATE:
return account.getNotificationsUpdates();
default: default:
return false; return false;
} }
@ -582,6 +595,8 @@ public class NotificationHelper {
return CHANNEL_FAVOURITE + account.getIdentifier(); return CHANNEL_FAVOURITE + account.getIdentifier();
case POLL: case POLL:
return CHANNEL_POLL + account.getIdentifier(); return CHANNEL_POLL + account.getIdentifier();
case SIGN_UP:
return CHANNEL_SIGN_UP + account.getIdentifier();
default: default:
return null; return null;
} }
@ -663,6 +678,10 @@ public class NotificationHelper {
} else { } else {
return context.getString(R.string.poll_ended_voted); return context.getString(R.string.poll_ended_voted);
} }
case SIGN_UP:
return String.format(context.getString(R.string.notification_sign_up_format), accountName);
case UPDATE:
return String.format(context.getString(R.string.notification_update_format), accountName);
} }
return null; return null;
} }
@ -671,6 +690,7 @@ public class NotificationHelper {
switch (notification.getType()) { switch (notification.getType()) {
case FOLLOW: case FOLLOW:
case FOLLOW_REQUEST: case FOLLOW_REQUEST:
case SIGN_UP:
return "@" + notification.getAccount().getUsername(); return "@" + notification.getAccount().getUsername();
case MENTION: case MENTION:
case FAVOURITE: case FAVOURITE:
@ -679,13 +699,13 @@ public class NotificationHelper {
if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) { if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) {
return notification.getStatus().getSpoilerText(); return notification.getStatus().getSpoilerText();
} else { } else {
return notification.getStatus().getContent().toString(); return parseAsMastodonHtml(notification.getStatus().getContent()).toString();
} }
case POLL: case POLL:
if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) { if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) {
return notification.getStatus().getSpoilerText(); return notification.getStatus().getSpoilerText();
} else { } else {
StringBuilder builder = new StringBuilder(notification.getStatus().getContent()); StringBuilder builder = new StringBuilder(parseAsMastodonHtml(notification.getStatus().getContent()));
builder.append('\n'); builder.append('\n');
Poll poll = notification.getStatus().getPoll(); Poll poll = notification.getStatus().getPoll();
List<PollOption> options = poll.getOptions(); List<PollOption> options = poll.getOptions();

View file

@ -1,240 +0,0 @@
package com.keylesspalace.tusky.components.preference
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.RadioButton
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.SplashActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.databinding.DialogEmojicompatBinding
import com.keylesspalace.tusky.databinding.ItemEmojiPrefBinding
import com.keylesspalace.tusky.util.EmojiCompatFont
import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.BLOBMOJI
import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.FONTS
import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.NOTOEMOJI
import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.SYSTEM_DEFAULT
import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.TWEMOJI
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import okhttp3.OkHttpClient
import kotlin.system.exitProcess
/**
* This Preference lets the user select their preferred emoji font
*/
class EmojiPreference(
context: Context,
private val okHttpClient: OkHttpClient
) : Preference(context) {
private lateinit var selected: EmojiCompatFont
private lateinit var original: EmojiCompatFont
private val radioButtons = mutableListOf<RadioButton>()
private var updated = false
private var currentNeedsUpdate = false
private val downloadDisposables = MutableList<Disposable?>(FONTS.size) { null }
override fun onAttachedToHierarchy(preferenceManager: PreferenceManager) {
super.onAttachedToHierarchy(preferenceManager)
// Find out which font is currently active
selected = EmojiCompatFont.byId(
PreferenceManager.getDefaultSharedPreferences(context).getInt(key, 0)
)
// We'll use this later to determine if anything has changed
original = selected
summary = selected.getDisplay(context)
}
override fun onClick() {
val binding = DialogEmojicompatBinding.inflate(LayoutInflater.from(context))
setupItem(BLOBMOJI, binding.itemBlobmoji)
setupItem(TWEMOJI, binding.itemTwemoji)
setupItem(NOTOEMOJI, binding.itemNotoemoji)
setupItem(SYSTEM_DEFAULT, binding.itemNomoji)
AlertDialog.Builder(context)
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ -> onDialogOk() }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun setupItem(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) {
// Initialize all the views
binding.emojiName.text = font.getDisplay(context)
binding.emojiCaption.setText(font.caption)
binding.emojiThumbnail.setImageResource(font.img)
// There needs to be a list of all the radio buttons in order to uncheck them when one is selected
radioButtons.add(binding.emojiRadioButton)
updateItem(font, binding)
// Set actions
binding.emojiDownload.setOnClickListener { startDownload(font, binding) }
binding.emojiDownloadCancel.setOnClickListener { cancelDownload(font, binding) }
binding.emojiRadioButton.setOnClickListener { radioButton: View -> select(font, radioButton as RadioButton) }
binding.root.setOnClickListener {
select(font, binding.emojiRadioButton)
}
}
private fun startDownload(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) {
// Switch to downloading style
binding.emojiDownload.hide()
binding.emojiCaption.visibility = View.INVISIBLE
binding.emojiProgress.show()
binding.emojiProgress.progress = 0
binding.emojiDownloadCancel.show()
font.downloadFontFile(context, okHttpClient)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ progress ->
// The progress is returned as a float between 0 and 1, or -1 if it could not determined
if (progress >= 0) {
binding.emojiProgress.isIndeterminate = false
val max = binding.emojiProgress.max.toFloat()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
binding.emojiProgress.setProgress((max * progress).toInt(), true)
} else {
binding.emojiProgress.progress = (max * progress).toInt()
}
} else {
binding.emojiProgress.isIndeterminate = true
}
},
{
Toast.makeText(context, R.string.download_failed, Toast.LENGTH_SHORT).show()
updateItem(font, binding)
},
{
finishDownload(font, binding)
}
).also { downloadDisposables[font.id] = it }
}
private fun cancelDownload(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) {
font.deleteDownloadedFile(context)
downloadDisposables[font.id]?.dispose()
downloadDisposables[font.id] = null
updateItem(font, binding)
}
private fun finishDownload(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) {
select(font, binding.emojiRadioButton)
updateItem(font, binding)
// Set the flag to restart the app (because an update has been downloaded)
if (selected === original && currentNeedsUpdate) {
updated = true
currentNeedsUpdate = false
}
}
/**
* Select a font both visually and logically
*
* @param font The font to be selected
* @param radio The radio button associated with it's visual item
*/
private fun select(font: EmojiCompatFont, radio: RadioButton) {
selected = font
radioButtons.forEach { radioButton ->
radioButton.isChecked = radioButton == radio
}
}
/**
* Called when a "consistent" state is reached, i.e. it's not downloading the font
*
* @param font The font to be displayed
* @param binding The ItemEmojiPrefBinding to show the item in
*/
private fun updateItem(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) {
// There's no download going on
binding.emojiProgress.hide()
binding.emojiDownloadCancel.hide()
binding.emojiCaption.show()
if (font.isDownloaded(context)) {
// Make it selectable
binding.emojiDownload.hide()
binding.emojiRadioButton.show()
binding.root.isClickable = true
} else {
// Make it downloadable
binding.emojiDownload.show()
binding.emojiRadioButton.hide()
binding.root.isClickable = false
}
// Select it if necessary
if (font === selected) {
binding.emojiRadioButton.isChecked = true
// Update available
if (!font.isDownloaded(context)) {
currentNeedsUpdate = true
}
} else {
binding.emojiRadioButton.isChecked = false
}
}
private fun saveSelectedFont() {
val index = selected.id
Log.i(TAG, "saveSelectedFont: Font ID: $index")
PreferenceManager
.getDefaultSharedPreferences(context)
.edit()
.putInt(key, index)
.apply()
summary = selected.getDisplay(context)
}
/**
* User clicked ok -> save the selected font and offer to restart the app if something changed
*/
private fun onDialogOk() {
saveSelectedFont()
if (selected !== original || updated) {
AlertDialog.Builder(context)
.setTitle(R.string.restart_required)
.setMessage(R.string.restart_emoji)
.setNegativeButton(R.string.later, null)
.setPositiveButton(R.string.restart) { _, _ ->
// Restart the app
// From https://stackoverflow.com/a/17166729/5070653
val launchIntent = Intent(context, SplashActivity::class.java)
val mPendingIntent = PendingIntent.getActivity(
context,
0x1f973, // This is the codepoint of the party face emoji :D
launchIntent,
NotificationHelper.pendingIntentFlags(false)
)
val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
mgr.set(
AlarmManager.RTC,
System.currentTimeMillis() + 100,
mPendingIntent
)
exitProcess(0)
}.show()
}
}
companion object {
private const val TAG = "EmojiPreference"
}
}

View file

@ -122,6 +122,28 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable {
true true
} }
} }
switchPreference {
setTitle(R.string.pref_title_notification_filter_sign_ups)
key = PrefKeys.NOTIFICATION_FILTER_SIGN_UPS
isIconSpaceReserved = false
isChecked = activeAccount.notificationsSignUps
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationsSignUps = newValue as Boolean }
true
}
}
switchPreference {
setTitle(R.string.pref_title_notification_filter_updates)
key = PrefKeys.NOTIFICATION_FILTER_UPDATES
isIconSpaceReserved = false
isChecked = activeAccount.notificationsUpdates
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationsUpdates = newValue as Boolean }
true
}
}
} }
preferenceCategory(R.string.pref_title_notification_alerts) { category -> preferenceCategory(R.string.pref_title_notification_alerts) { category ->

View file

@ -38,14 +38,11 @@ import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizePx import com.mikepenz.iconics.utils.sizePx
import okhttp3.OkHttpClient import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference
import javax.inject.Inject import javax.inject.Inject
class PreferencesFragment : PreferenceFragmentCompat(), Injectable { class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
@Inject
lateinit var okhttpclient: OkHttpClient
@Inject @Inject
lateinit var accountManager: AccountManager lateinit var accountManager: AccountManager
@ -65,11 +62,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
icon = makeIcon(GoogleMaterial.Icon.gmd_palette) icon = makeIcon(GoogleMaterial.Icon.gmd_palette)
} }
emojiPreference(okhttpclient) { emojiPreference(requireActivity()) {
setDefaultValue("system_default")
setIcon(R.drawable.ic_emoji_24dp)
key = PrefKeys.EMOJI
setSummary(R.string.system_default)
setTitle(R.string.emoji_style) setTitle(R.string.emoji_style)
icon = makeIcon(GoogleMaterial.Icon.gmd_sentiment_satisfied) icon = makeIcon(GoogleMaterial.Icon.gmd_sentiment_satisfied)
} }
@ -300,6 +293,12 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
} }
} }
override fun onDisplayPreferenceDialog(preference: Preference) {
if (!EmojiPickerPreference.onDisplayPreferenceDialog(this, preference)) {
super.onDisplayPreferenceDialog(preference)
}
}
companion object { companion object {
fun newInstance(): PreferencesFragment { fun newInstance(): PreferencesFragment {
return PreferencesFragment() return PreferencesFragment()

View file

@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.map
import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MuteEvent import com.keylesspalace.tusky.appstore.MuteEvent
@ -34,11 +35,13 @@ import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.toViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -74,6 +77,11 @@ class ReportViewModel @Inject constructor(
pagingSourceFactory = { StatusesPagingSource(accountId, mastodonApi) } pagingSourceFactory = { StatusesPagingSource(accountId, mastodonApi) }
).flow ).flow
} }
.map { pagingData ->
/* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData.Concrete
instead of StatusViewState */
pagingData.map { status -> status.toViewData(false, false, false) }
}
.cachedIn(viewModelScope) .cachedIn(viewModelScope)
private val selectedIds = HashSet<String>() private val selectedIds = HashSet<String>()
@ -155,7 +163,7 @@ class ReportViewModel @Inject constructor(
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ relationship -> { relationship ->
val muting = relationship?.muting == true val muting = relationship.muting
muteStateMutable.value = Success(muting) muteStateMutable.value = Success(muting)
if (muting) { if (muting) {
eventHub.dispatch(MuteEvent(accountId)) eventHub.dispatch(MuteEvent(accountId))
@ -180,7 +188,7 @@ class ReportViewModel @Inject constructor(
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ relationship -> { relationship ->
val blocking = relationship?.blocking == true val blocking = relationship.blocking
blockStateMutable.value = Success(blocking) blockStateMutable.value = Success(blocking)
if (blocking) { if (blocking) {
eventHub.dispatch(BlockEvent(accountId)) eventHub.dispatch(BlockEvent(accountId))

View file

@ -26,6 +26,7 @@ import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.StatusViewHelper import com.keylesspalace.tusky.util.StatusViewHelper
import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER
@ -37,6 +38,7 @@ import com.keylesspalace.tusky.util.setClickableMentions
import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.shouldTrimStatus import com.keylesspalace.tusky.util.shouldTrimStatus
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.toViewData import com.keylesspalace.tusky.viewdata.toViewData
import java.util.Date import java.util.Date
@ -45,20 +47,22 @@ class StatusViewHolder(
private val statusDisplayOptions: StatusDisplayOptions, private val statusDisplayOptions: StatusDisplayOptions,
private val viewState: StatusViewState, private val viewState: StatusViewState,
private val adapterHandler: AdapterHandler, private val adapterHandler: AdapterHandler,
private val getStatusForPosition: (Int) -> Status? private val getStatusForPosition: (Int) -> StatusViewData.Concrete?
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height) private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height)
private val statusViewHelper = StatusViewHelper(itemView) private val statusViewHelper = StatusViewHelper(itemView)
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
private val previewListener = object : StatusViewHelper.MediaPreviewListener { private val previewListener = object : StatusViewHelper.MediaPreviewListener {
override fun onViewMedia(v: View?, idx: Int) { override fun onViewMedia(v: View?, idx: Int) {
status()?.let { status -> viewdata()?.let { viewdata ->
adapterHandler.showMedia(v, status, idx) adapterHandler.showMedia(v, viewdata.status, idx)
} }
} }
override fun onContentHiddenChange(isShowing: Boolean) { override fun onContentHiddenChange(isShowing: Boolean) {
status()?.id?.let { id -> viewdata()?.id?.let { id ->
viewState.setMediaShow(id, isShowing) viewState.setMediaShow(id, isShowing)
} }
} }
@ -66,57 +70,57 @@ class StatusViewHolder(
init { init {
binding.statusSelection.setOnCheckedChangeListener { _, isChecked -> binding.statusSelection.setOnCheckedChangeListener { _, isChecked ->
status()?.let { status -> viewdata()?.let { viewdata ->
adapterHandler.setStatusChecked(status, isChecked) adapterHandler.setStatusChecked(viewdata.status, isChecked)
} }
} }
binding.statusMediaPreviewContainer.clipToOutline = true binding.statusMediaPreviewContainer.clipToOutline = true
} }
fun bind(status: Status) { fun bind(viewData: StatusViewData.Concrete) {
binding.statusSelection.isChecked = adapterHandler.isStatusChecked(status.id) binding.statusSelection.isChecked = adapterHandler.isStatusChecked(viewData.id)
updateTextView() updateTextView()
val sensitive = status.sensitive val sensitive = viewData.status.sensitive
statusViewHelper.setMediasPreview( statusViewHelper.setMediasPreview(
statusDisplayOptions, status.attachments, statusDisplayOptions, viewData.status.attachments,
sensitive, previewListener, viewState.isMediaShow(status.id, status.sensitive), sensitive, previewListener, viewState.isMediaShow(viewData.id, viewData.status.sensitive),
mediaViewHeight mediaViewHeight
) )
statusViewHelper.setupPollReadonly(status.poll.toViewData(), status.emojis, statusDisplayOptions) statusViewHelper.setupPollReadonly(viewData.status.poll.toViewData(), viewData.status.emojis, statusDisplayOptions)
setCreatedAt(status.createdAt) setCreatedAt(viewData.status.createdAt)
} }
private fun updateTextView() { private fun updateTextView() {
status()?.let { status -> viewdata()?.let { viewdata ->
setupCollapsedState( setupCollapsedState(
shouldTrimStatus(status.content), viewState.isCollapsed(status.id, true), shouldTrimStatus(viewdata.content), viewState.isCollapsed(viewdata.id, true),
viewState.isContentShow(status.id, status.sensitive), status.spoilerText viewState.isContentShow(viewdata.id, viewdata.status.sensitive), viewdata.spoilerText
) )
if (status.spoilerText.isBlank()) { if (viewdata.spoilerText.isBlank()) {
setTextVisible(true, status.content, status.mentions, status.tags, status.emojis, adapterHandler) setTextVisible(true, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler)
binding.statusContentWarningButton.hide() binding.statusContentWarningButton.hide()
binding.statusContentWarningDescription.hide() binding.statusContentWarningDescription.hide()
} else { } else {
val emojiSpoiler = status.spoilerText.emojify(status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis) val emojiSpoiler = viewdata.spoilerText.emojify(viewdata.status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis)
binding.statusContentWarningDescription.text = emojiSpoiler binding.statusContentWarningDescription.text = emojiSpoiler
binding.statusContentWarningDescription.show() binding.statusContentWarningDescription.show()
binding.statusContentWarningButton.show() binding.statusContentWarningButton.show()
setContentWarningButtonText(viewState.isContentShow(status.id, true)) setContentWarningButtonText(viewState.isContentShow(viewdata.id, true))
binding.statusContentWarningButton.setOnClickListener { binding.statusContentWarningButton.setOnClickListener {
status()?.let { status -> viewdata()?.let { viewdata ->
val contentShown = viewState.isContentShow(status.id, true) val contentShown = viewState.isContentShow(viewdata.id, true)
binding.statusContentWarningDescription.invalidate() binding.statusContentWarningDescription.invalidate()
viewState.setContentShow(status.id, !contentShown) viewState.setContentShow(viewdata.id, !contentShown)
setTextVisible(!contentShown, status.content, status.mentions, status.tags, status.emojis, adapterHandler) setTextVisible(!contentShown, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler)
setContentWarningButtonText(!contentShown) setContentWarningButtonText(!contentShown)
} }
} }
setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.tags, status.emojis, adapterHandler) setTextVisible(viewState.isContentShow(viewdata.id, true), viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler)
} }
} }
} }
@ -152,7 +156,7 @@ class StatusViewHolder(
private fun setCreatedAt(createdAt: Date?) { private fun setCreatedAt(createdAt: Date?) {
if (statusDisplayOptions.useAbsoluteTime) { if (statusDisplayOptions.useAbsoluteTime) {
binding.timestampInfo.text = statusViewHelper.getAbsoluteTime(createdAt) binding.timestampInfo.text = absoluteTimeFormatter.format(createdAt)
} else { } else {
binding.timestampInfo.text = if (createdAt != null) { binding.timestampInfo.text = if (createdAt != null) {
val then = createdAt.time val then = createdAt.time
@ -169,8 +173,8 @@ class StatusViewHolder(
/* input filter for TextViews have to be set before text */ /* input filter for TextViews have to be set before text */
if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) { if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) {
binding.buttonToggleContent.setOnClickListener { binding.buttonToggleContent.setOnClickListener {
status()?.let { status -> viewdata()?.let { viewdata ->
viewState.setCollapsed(status.id, !collapsed) viewState.setCollapsed(viewdata.id, !collapsed)
updateTextView() updateTextView()
} }
} }
@ -189,5 +193,5 @@ class StatusViewHolder(
} }
} }
private fun status() = getStatusForPosition(bindingAdapterPosition) private fun viewdata() = getStatusForPosition(bindingAdapterPosition)
} }

View file

@ -22,16 +22,16 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.components.report.model.StatusViewState import com.keylesspalace.tusky.components.report.model.StatusViewState
import com.keylesspalace.tusky.databinding.ItemReportStatusBinding import com.keylesspalace.tusky.databinding.ItemReportStatusBinding
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.StatusViewData
class StatusesAdapter( class StatusesAdapter(
private val statusDisplayOptions: StatusDisplayOptions, private val statusDisplayOptions: StatusDisplayOptions,
private val statusViewState: StatusViewState, private val statusViewState: StatusViewState,
private val adapterHandler: AdapterHandler private val adapterHandler: AdapterHandler
) : PagingDataAdapter<Status, StatusViewHolder>(STATUS_COMPARATOR) { ) : PagingDataAdapter<StatusViewData.Concrete, StatusViewHolder>(STATUS_COMPARATOR) {
private val statusForPosition: (Int) -> Status? = { position: Int -> private val statusForPosition: (Int) -> StatusViewData.Concrete? = { position: Int ->
if (position != RecyclerView.NO_POSITION) getItem(position) else null if (position != RecyclerView.NO_POSITION) getItem(position) else null
} }
@ -50,11 +50,11 @@ class StatusesAdapter(
} }
companion object { companion object {
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Status>() { val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<StatusViewData.Concrete>() {
override fun areContentsTheSame(oldItem: Status, newItem: Status): Boolean = override fun areContentsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean =
oldItem == newItem oldItem == newItem
override fun areItemsTheSame(oldItem: Status, newItem: Status): Boolean = override fun areItemsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean =
oldItem.id == newItem.id oldItem.id == newItem.id
} }
} }

View file

@ -25,7 +25,6 @@ import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.entity.ScheduledStatus
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import javax.inject.Inject import javax.inject.Inject
class ScheduledStatusViewModel @Inject constructor( class ScheduledStatusViewModel @Inject constructor(
@ -43,12 +42,14 @@ class ScheduledStatusViewModel @Inject constructor(
fun deleteScheduledStatus(status: ScheduledStatus) { fun deleteScheduledStatus(status: ScheduledStatus) {
viewModelScope.launch { viewModelScope.launch {
try { mastodonApi.deleteScheduledStatus(status.id).fold(
mastodonApi.deleteScheduledStatus(status.id).await() {
pagingSourceFactory.remove(status) pagingSourceFactory.remove(status)
} catch (throwable: Throwable) { },
Log.w("ScheduledTootViewModel", "Error deleting scheduled status", throwable) { throwable ->
} Log.w("ScheduledTootViewModel", "Error deleting scheduled status", throwable)
}
)
} }
} }
} }

View file

@ -84,6 +84,10 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector {
return true return true
} }
override fun finish() {
super.finishWithoutSlideOutAnimation()
}
private fun getPageTitle(position: Int): CharSequence { private fun getPageTitle(position: Int): CharSequence {
return when (position) { return when (position) {
0 -> getString(R.string.title_posts) 0 -> getString(R.string.title_posts)

View file

@ -111,9 +111,13 @@ abstract class SearchFragment<T : Any> :
} }
} }
override fun onViewAccount(id: String) = startActivity(AccountActivity.getIntent(requireContext(), id)) override fun onViewAccount(id: String) {
bottomSheetActivity?.startActivityWithSlideInAnimation(AccountActivity.getIntent(requireContext(), id))
}
override fun onViewTag(tag: String) = startActivity(StatusListActivity.newHashtagIntent(requireContext(), tag)) override fun onViewTag(tag: String) {
bottomSheetActivity?.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
}
override fun onViewUrl(url: String) { override fun onViewUrl(url: String) {
bottomSheetActivity?.viewUrl(url) bottomSheetActivity?.viewUrl(url)

View file

@ -97,7 +97,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
} }
override fun onReply(position: Int) { override fun onReply(position: Int) {
searchAdapter.peek(position)?.status?.let { status -> searchAdapter.peek(position)?.let { status ->
reply(status) reply(status)
} }
} }
@ -199,8 +199,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
fun newInstance() = SearchStatusesFragment() fun newInstance() = SearchStatusesFragment()
} }
private fun reply(status: Status) { private fun reply(status: StatusViewData.Concrete) {
val actionableStatus = status.actionableStatus val actionableStatus = status.actionable
val mentionedUsernames = actionableStatus.mentions.map { it.username } val mentionedUsernames = actionableStatus.mentions.map { it.username }
.toMutableSet() .toMutableSet()
.apply { .apply {
@ -216,10 +216,10 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
contentWarning = actionableStatus.spoilerText, contentWarning = actionableStatus.spoilerText,
mentionedUsernames = mentionedUsernames, mentionedUsernames = mentionedUsernames,
replyingStatusAuthor = actionableStatus.account.localUsername, replyingStatusAuthor = actionableStatus.account.localUsername,
replyingStatusContent = actionableStatus.content.toString() replyingStatusContent = status.content.toString()
) )
) )
startActivity(intent) bottomSheetActivity?.startActivityWithSlideInAnimation(intent)
} }
private fun more(status: Status, view: View, position: Int) { private fun more(status: Status, view: View, position: Int) {

View file

@ -172,7 +172,7 @@ class TimelineFragment :
setupRecyclerView() setupRecyclerView()
adapter.addLoadStateListener { loadState -> adapter.addLoadStateListener { loadState ->
if (loadState.refresh != LoadState.Loading) { if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false binding.swipeRefreshLayout.isRefreshing = false
} }

View file

@ -15,22 +15,18 @@
package com.keylesspalace.tusky.components.timeline package com.keylesspalace.tusky.components.timeline
import android.text.SpannedString
import androidx.core.text.parseAsHtml
import androidx.core.text.toHtml
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import com.keylesspalace.tusky.db.TimelineAccountEntity import com.keylesspalace.tusky.db.TimelineAccountEntity
import com.keylesspalace.tusky.db.TimelineStatusEntity import com.keylesspalace.tusky.db.TimelineStatusEntity
import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Card
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.shouldTrimStatus
import com.keylesspalace.tusky.util.trimTrailingWhitespace
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import java.util.Date import java.util.Date
@ -101,7 +97,8 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
expanded = loading, expanded = loading,
contentCollapsed = false, contentCollapsed = false,
contentShowing = false, contentShowing = false,
pinned = false pinned = false,
card = null,
) )
} }
@ -119,7 +116,7 @@ fun Status.toEntity(
authorServerId = actionableStatus.account.id, authorServerId = actionableStatus.account.id,
inReplyToId = actionableStatus.inReplyToId, inReplyToId = actionableStatus.inReplyToId,
inReplyToAccountId = actionableStatus.inReplyToAccountId, inReplyToAccountId = actionableStatus.inReplyToAccountId,
content = actionableStatus.content.toHtml(), content = actionableStatus.content,
createdAt = actionableStatus.createdAt.time, createdAt = actionableStatus.createdAt.time,
emojis = actionableStatus.emojis.let(gson::toJson), emojis = actionableStatus.emojis.let(gson::toJson),
reblogsCount = actionableStatus.reblogsCount, reblogsCount = actionableStatus.reblogsCount,
@ -141,7 +138,8 @@ fun Status.toEntity(
expanded = expanded, expanded = expanded,
contentShowing = contentShowing, contentShowing = contentShowing,
contentCollapsed = contentCollapsed, contentCollapsed = contentCollapsed,
pinned = actionableStatus.pinned == true pinned = actionableStatus.pinned == true,
card = actionableStatus.card?.let(gson::toJson),
) )
} }
@ -156,6 +154,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
val application = gson.fromJson(status.application, Status.Application::class.java) val application = gson.fromJson(status.application, Status.Application::class.java)
val emojis: List<Emoji> = gson.fromJson(status.emojis, emojisListType) ?: emptyList() val emojis: List<Emoji> = gson.fromJson(status.emojis, emojisListType) ?: emptyList()
val poll: Poll? = gson.fromJson(status.poll, Poll::class.java) val poll: Poll? = gson.fromJson(status.poll, Poll::class.java)
val card: Card? = gson.fromJson(status.card, Card::class.java)
val reblog = status.reblogServerId?.let { id -> val reblog = status.reblogServerId?.let { id ->
Status( Status(
@ -165,8 +164,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
inReplyToId = status.inReplyToId, inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId, inReplyToAccountId = status.inReplyToAccountId,
reblog = null, reblog = null,
content = status.content?.parseAsHtml()?.trimTrailingWhitespace() content = status.content.orEmpty(),
?: SpannedString(""),
createdAt = Date(status.createdAt), createdAt = Date(status.createdAt),
emojis = emojis, emojis = emojis,
reblogsCount = status.reblogsCount, reblogsCount = status.reblogsCount,
@ -184,7 +182,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
pinned = false, pinned = false,
muted = status.muted, muted = status.muted,
poll = poll, poll = poll,
card = null card = card,
) )
} }
val status = if (reblog != null) { val status = if (reblog != null) {
@ -195,7 +193,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
inReplyToId = null, inReplyToId = null,
inReplyToAccountId = null, inReplyToAccountId = null,
reblog = reblog, reblog = reblog,
content = SpannedString(""), content = "",
createdAt = Date(status.createdAt), // lie but whatever? createdAt = Date(status.createdAt), // lie but whatever?
emojis = listOf(), emojis = listOf(),
reblogsCount = 0, reblogsCount = 0,
@ -223,8 +221,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
inReplyToId = status.inReplyToId, inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId, inReplyToAccountId = status.inReplyToAccountId,
reblog = null, reblog = null,
content = status.content?.parseAsHtml()?.trimTrailingWhitespace() content = status.content.orEmpty(),
?: SpannedString(""),
createdAt = Date(status.createdAt), createdAt = Date(status.createdAt),
emojis = emojis, emojis = emojis,
reblogsCount = status.reblogsCount, reblogsCount = status.reblogsCount,
@ -242,14 +239,13 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
pinned = status.pinned, pinned = status.pinned,
muted = status.muted, muted = status.muted,
poll = poll, poll = poll,
card = null card = card,
) )
} }
return StatusViewData.Concrete( return StatusViewData.Concrete(
status = status, status = status,
isExpanded = this.status.expanded, isExpanded = this.status.expanded,
isShowingContent = this.status.contentShowing, isShowingContent = this.status.contentShowing,
isCollapsible = shouldTrimStatus(status.content),
isCollapsed = this.status.contentCollapsed isCollapsed = this.status.contentCollapsed
) )
} }

View file

@ -42,7 +42,10 @@ import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
@ -79,15 +82,13 @@ class CachedTimelineViewModel @Inject constructor(
} }
).flow ).flow
.map { pagingData -> .map { pagingData ->
pagingData.map { timelineStatus -> pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus ->
timelineStatus.toViewData(gson) timelineStatus.toViewData(gson)
} }.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
}
.map { pagingData ->
pagingData.filter { statusViewData ->
!shouldFilterStatus(statusViewData) !shouldFilterStatus(statusViewData)
} }
} }
.flowOn(Dispatchers.Default)
.cachedIn(viewModelScope) .cachedIn(viewModelScope)
init { init {

View file

@ -40,6 +40,9 @@ import com.keylesspalace.tusky.util.isLessThan
import com.keylesspalace.tusky.util.isLessThanOrEqual import com.keylesspalace.tusky.util.isLessThanOrEqual
import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
@ -79,10 +82,11 @@ class NetworkTimelineViewModel @Inject constructor(
remoteMediator = NetworkTimelineRemoteMediator(accountManager, this) remoteMediator = NetworkTimelineRemoteMediator(accountManager, this)
).flow ).flow
.map { pagingData -> .map { pagingData ->
pagingData.filter { statusViewData -> pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
!shouldFilterStatus(statusViewData) !shouldFilterStatus(statusViewData)
} }
} }
.flowOn(Dispatchers.Default)
.cachedIn(viewModelScope) .cachedIn(viewModelScope)
override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) {

View file

@ -50,6 +50,8 @@ data class AccountEntity(
var notificationsFavorited: Boolean = true, var notificationsFavorited: Boolean = true,
var notificationsPolls: Boolean = true, var notificationsPolls: Boolean = true,
var notificationsSubscriptions: Boolean = true, var notificationsSubscriptions: Boolean = true,
var notificationsSignUps: Boolean = true,
var notificationsUpdates: Boolean = true,
var notificationSound: Boolean = true, var notificationSound: Boolean = true,
var notificationVibration: Boolean = true, var notificationVibration: Boolean = true,
var notificationLight: Boolean = true, var notificationLight: Boolean = true,

View file

@ -31,7 +31,7 @@ import java.io.File;
*/ */
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class TimelineAccountEntity.class, ConversationEntity.class
}, version = 31) }, version = 35)
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao(); public abstract AccountDao accountDao();
@ -483,4 +483,62 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("DELETE FROM `TimelineStatusEntity`"); database.execSQL("DELETE FROM `TimelineStatusEntity`");
} }
}; };
public static final Migration MIGRATION_31_32 = new Migration(31, 32) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsSignUps` INTEGER NOT NULL DEFAULT 1");
}
};
public static final Migration MIGRATION_32_33 = new Migration(32, 33) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
// ConversationEntity lost the s_collapsible column
// since SQLite does not support removing columns and it is just a cache table, we recreate the whole table.
database.execSQL("DROP TABLE `ConversationEntity`");
database.execSQL("CREATE TABLE IF NOT EXISTS `ConversationEntity` (" +
"`accountId` INTEGER NOT NULL," +
"`id` TEXT NOT NULL," +
"`accounts` TEXT NOT NULL," +
"`unread` INTEGER NOT NULL," +
"`s_id` TEXT NOT NULL," +
"`s_url` TEXT," +
"`s_inReplyToId` TEXT," +
"`s_inReplyToAccountId` TEXT," +
"`s_account` TEXT NOT NULL," +
"`s_content` TEXT NOT NULL," +
"`s_createdAt` INTEGER NOT NULL," +
"`s_emojis` TEXT NOT NULL," +
"`s_favouritesCount` INTEGER NOT NULL," +
"`s_favourited` INTEGER NOT NULL," +
"`s_bookmarked` INTEGER NOT NULL," +
"`s_sensitive` INTEGER NOT NULL," +
"`s_spoilerText` TEXT NOT NULL," +
"`s_attachments` TEXT NOT NULL," +
"`s_mentions` TEXT NOT NULL," +
"`s_tags` TEXT," +
"`s_showingHiddenContent` INTEGER NOT NULL," +
"`s_expanded` INTEGER NOT NULL," +
"`s_collapsed` INTEGER NOT NULL," +
"`s_muted` INTEGER NOT NULL," +
"`s_poll` TEXT," +
"PRIMARY KEY(`id`, `accountId`))");
}
};
public static final Migration MIGRATION_33_34 = new Migration(33, 34) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsUpdates` INTEGER NOT NULL DEFAULT 1");
}
};
public static final Migration MIGRATION_34_35 = new Migration(34, 35) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `card` TEXT");
}
};
} }

View file

@ -17,7 +17,6 @@ package com.keylesspalace.tusky.db
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
@ -31,8 +30,8 @@ interface ConversationsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(conversation: ConversationEntity): Long suspend fun insert(conversation: ConversationEntity): Long
@Delete @Query("DELETE FROM ConversationEntity WHERE id = :id AND accountId = :accountId")
suspend fun delete(conversation: ConversationEntity): Int suspend fun delete(id: String, accountId: Long): Int
@Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC") @Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC")
fun conversationsForAccount(accountId: Long): PagingSource<Int, ConversationEntity> fun conversationsForAccount(accountId: Long): PagingSource<Int, ConversationEntity>

View file

@ -15,9 +15,6 @@
package com.keylesspalace.tusky.db package com.keylesspalace.tusky.db
import android.text.Spanned
import androidx.core.text.parseAsHtml
import androidx.core.text.toHtml
import androidx.room.ProvidedTypeConverter import androidx.room.ProvidedTypeConverter
import androidx.room.TypeConverter import androidx.room.TypeConverter
import com.google.gson.Gson import com.google.gson.Gson
@ -31,10 +28,8 @@ import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.trimTrailingWhitespace
import java.net.URLDecoder import java.net.URLDecoder
import java.net.URLEncoder import java.net.URLEncoder
import java.util.ArrayList
import java.util.Date import java.util.Date
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -140,22 +135,6 @@ class Converters @Inject constructor (
return Date(date) return Date(date)
} }
@TypeConverter
fun spannedToString(spanned: Spanned?): String? {
if (spanned == null) {
return null
}
return spanned.toHtml()
}
@TypeConverter
fun stringToSpanned(spannedString: String?): Spanned? {
if (spannedString == null) {
return null
}
return spannedString.parseAsHtml().trimTrailingWhitespace()
}
@TypeConverter @TypeConverter
fun pollToJson(poll: Poll?): String? { fun pollToJson(poll: Poll?): String? {
return gson.toJson(poll) return gson.toJson(poll)

View file

@ -19,13 +19,19 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import io.reactivex.rxjava3.core.Single
@Dao @Dao
interface InstanceDao { interface InstanceDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(instance: InstanceEntity) @Insert(onConflict = OnConflictStrategy.REPLACE, entity = InstanceEntity::class)
suspend fun insertOrReplace(instance: InstanceInfoEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE, entity = InstanceEntity::class)
suspend fun insertOrReplace(emojis: EmojisEntity)
@Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1")
fun loadMetadataForInstance(instance: String): Single<InstanceEntity> suspend fun getInstanceInfo(instance: String): InstanceInfoEntity?
@Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1")
suspend fun getEmojiInfo(instance: String): EmojisEntity?
} }

View file

@ -23,7 +23,7 @@ import com.keylesspalace.tusky.entity.Emoji
@Entity @Entity
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
data class InstanceEntity( data class InstanceEntity(
@field:PrimaryKey var instance: String, @PrimaryKey val instance: String,
val emojiList: List<Emoji>?, val emojiList: List<Emoji>?,
val maximumTootCharacters: Int?, val maximumTootCharacters: Int?,
val maxPollOptions: Int?, val maxPollOptions: Int?,
@ -33,3 +33,20 @@ data class InstanceEntity(
val charactersReservedPerUrl: Int?, val charactersReservedPerUrl: Int?,
val version: String? val version: String?
) )
@TypeConverters(Converters::class)
data class EmojisEntity(
@PrimaryKey val instance: String,
val emojiList: List<Emoji>?
)
data class InstanceInfoEntity(
@PrimaryKey val instance: String,
val maximumTootCharacters: Int?,
val maxPollOptions: Int?,
val maxPollOptionLength: Int?,
val minPollDuration: Int?,
val maxPollDuration: Int?,
val charactersReservedPerUrl: Int?,
val version: String?
)

View file

@ -36,7 +36,7 @@ SELECT s.serverId, s.url, s.timelineUserId,
s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt,
s.emojis, s.reblogsCount, s.favouritesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, s.emojis, s.reblogsCount, s.favouritesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive,
s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId, s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId,
s.content, s.attachments, s.poll, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned,
a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId',
a.localUsername as 'a_localUsername', a.username as 'a_username', a.localUsername as 'a_localUsername', a.username as 'a_username',
a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar',

View file

@ -78,7 +78,8 @@ data class TimelineStatusEntity(
val expanded: Boolean, // used as the "loading" attribute when this TimelineStatusEntity is a placeholder val expanded: Boolean, // used as the "loading" attribute when this TimelineStatusEntity is a placeholder
val contentCollapsed: Boolean, val contentCollapsed: Boolean,
val contentShowing: Boolean, val contentShowing: Boolean,
val pinned: Boolean val pinned: Boolean,
val card: String?,
) )
@Entity( @Entity(

View file

@ -62,7 +62,8 @@ class AppModule {
AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25, AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25,
AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")), AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")),
AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29, AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29,
AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31 AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32,
AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34, AppDatabase.MIGRATION_34_35,
) )
.build() .build()
} }

View file

@ -18,14 +18,13 @@ package com.keylesspalace.tusky.di
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Build import android.os.Build
import android.text.Spanned import at.connyduck.calladapter.kotlinresult.KotlinResultCallAdapterFactory
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.json.SpannedTypeAdapter
import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.MediaUploadApi
import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.getNonNullString
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -51,11 +50,7 @@ class NetworkModule {
@Provides @Provides
@Singleton @Singleton
fun providesGson(): Gson { fun providesGson() = Gson()
return GsonBuilder()
.registerTypeAdapter(Spanned::class.java, SpannedTypeAdapter())
.create()
}
@Provides @Provides
@Singleton @Singleton
@ -111,10 +106,25 @@ class NetworkModule {
.client(httpClient) .client(httpClient)
.addConverterFactory(GsonConverterFactory.create(gson)) .addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(RxJava3CallAdapterFactory.create()) .addCallAdapterFactory(RxJava3CallAdapterFactory.create())
.addCallAdapterFactory(KotlinResultCallAdapterFactory.create())
.build() .build()
} }
@Provides @Provides
@Singleton @Singleton
fun providesApi(retrofit: Retrofit): MastodonApi = retrofit.create() fun providesApi(retrofit: Retrofit): MastodonApi = retrofit.create()
@Provides
@Singleton
fun providesMediaUploadApi(retrofit: Retrofit, okHttpClient: OkHttpClient): MediaUploadApi {
val longTimeOutOkHttpClient = okHttpClient.newBuilder()
.readTimeout(100, TimeUnit.SECONDS)
.writeTimeout(100, TimeUnit.SECONDS)
.build()
return retrofit.newBuilder()
.client(longTimeOutOkHttpClient)
.build()
.create()
}
} }

View file

@ -15,7 +15,6 @@
package com.keylesspalace.tusky.entity package com.keylesspalace.tusky.entity
import android.text.Spanned
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import java.util.Date import java.util.Date
@ -24,7 +23,7 @@ data class Account(
@SerializedName("username") val localUsername: String, @SerializedName("username") val localUsername: String,
@SerializedName("acct") val username: String, @SerializedName("acct") val username: String,
@SerializedName("display_name") val displayName: String?, // should never be null per Api definition, but some servers break the contract @SerializedName("display_name") val displayName: String?, // should never be null per Api definition, but some servers break the contract
val note: Spanned, val note: String,
val url: String, val url: String,
val avatar: String, val avatar: String,
val header: String, val header: String,
@ -46,56 +45,6 @@ data class Account(
} else displayName } else displayName
fun isRemote(): Boolean = this.username != this.localUsername fun isRemote(): Boolean = this.username != this.localUsername
/**
* overriding equals & hashcode because Spanned does not always compare correctly otherwise
*/
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Account
if (id != other.id) return false
if (localUsername != other.localUsername) return false
if (username != other.username) return false
if (displayName != other.displayName) return false
if (note.toString() != other.note.toString()) return false
if (url != other.url) return false
if (avatar != other.avatar) return false
if (header != other.header) return false
if (locked != other.locked) return false
if (followersCount != other.followersCount) return false
if (followingCount != other.followingCount) return false
if (statusesCount != other.statusesCount) return false
if (source != other.source) return false
if (bot != other.bot) return false
if (emojis != other.emojis) return false
if (fields != other.fields) return false
if (moved != other.moved) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + localUsername.hashCode()
result = 31 * result + username.hashCode()
result = 31 * result + (displayName?.hashCode() ?: 0)
result = 31 * result + note.toString().hashCode()
result = 31 * result + url.hashCode()
result = 31 * result + avatar.hashCode()
result = 31 * result + header.hashCode()
result = 31 * result + locked.hashCode()
result = 31 * result + followersCount
result = 31 * result + followingCount
result = 31 * result + statusesCount
result = 31 * result + (source?.hashCode() ?: 0)
result = 31 * result + bot.hashCode()
result = 31 * result + (emojis?.hashCode() ?: 0)
result = 31 * result + (fields?.hashCode() ?: 0)
result = 31 * result + (moved?.hashCode() ?: 0)
return result
}
} }
data class AccountSource( data class AccountSource(
@ -107,7 +56,7 @@ data class AccountSource(
data class Field( data class Field(
val name: String, val name: String,
val value: Spanned, val value: String,
@SerializedName("verified_at") val verifiedAt: Date? @SerializedName("verified_at") val verifiedAt: Date?
) )

View file

@ -15,13 +15,12 @@
package com.keylesspalace.tusky.entity package com.keylesspalace.tusky.entity
import android.text.Spanned
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import java.util.Date import java.util.Date
data class Announcement( data class Announcement(
val id: String, val id: String,
val content: Spanned, val content: String,
@SerializedName("starts_at") val startsAt: Date?, @SerializedName("starts_at") val startsAt: Date?,
@SerializedName("ends_at") val endsAt: Date?, @SerializedName("ends_at") val endsAt: Date?,
@SerializedName("all_day") val allDay: Boolean, @SerializedName("all_day") val allDay: Boolean,

View file

@ -15,13 +15,12 @@
package com.keylesspalace.tusky.entity package com.keylesspalace.tusky.entity
import android.text.Spanned
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
data class Card( data class Card(
val url: String, val url: String,
val title: Spanned, val title: String,
val description: Spanned, val description: String,
@SerializedName("author_name") val authorName: String, @SerializedName("author_name") val authorName: String,
val image: String, val image: String,
val type: String, val type: String,
@ -31,9 +30,7 @@ data class Card(
val embed_url: String? val embed_url: String?
) { ) {
override fun hashCode(): Int { override fun hashCode() = url.hashCode()
return url.hashCode()
}
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (other !is Card) { if (other !is Card) {

View file

@ -1,9 +0,0 @@
package com.keylesspalace.tusky.entity
import com.google.gson.annotations.SerializedName
data class IdentityProof(
val provider: String,
@SerializedName("provider_username") val username: String,
@SerializedName("profile_url") val profileUrl: String
)

View file

@ -37,7 +37,10 @@ data class Notification(
FOLLOW("follow"), FOLLOW("follow"),
FOLLOW_REQUEST("follow_request"), FOLLOW_REQUEST("follow_request"),
POLL("poll"), POLL("poll"),
STATUS("status"); STATUS("status"),
SIGN_UP("admin.sign_up"),
UPDATE("update"),
;
companion object { companion object {
@ -49,7 +52,7 @@ data class Notification(
} }
return UNKNOWN return UNKNOWN
} }
val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS) val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE)
} }
override fun toString(): String { override fun toString(): String {

View file

@ -16,9 +16,9 @@
package com.keylesspalace.tusky.entity package com.keylesspalace.tusky.entity
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.URLSpan import android.text.style.URLSpan
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import java.util.ArrayList import java.util.ArrayList
import java.util.Date import java.util.Date
@ -29,7 +29,7 @@ data class Status(
@SerializedName("in_reply_to_id") var inReplyToId: String?, @SerializedName("in_reply_to_id") var inReplyToId: String?,
@SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?, @SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?,
val reblog: Status?, val reblog: Status?,
val content: Spanned, val content: String,
@SerializedName("created_at") val createdAt: Date, @SerializedName("created_at") val createdAt: Date,
val emojis: List<Emoji>, val emojis: List<Emoji>,
@SerializedName("reblogs_count") val reblogsCount: Int, @SerializedName("reblogs_count") val reblogsCount: Int,
@ -134,8 +134,9 @@ data class Status(
} }
private fun getEditableText(): String { private fun getEditableText(): String {
val builder = SpannableStringBuilder(content) val contentSpanned = content.parseAsMastodonHtml()
for (span in content.getSpans(0, content.length, URLSpan::class.java)) { val builder = SpannableStringBuilder(content.parseAsMastodonHtml())
for (span in contentSpanned.getSpans(0, content.length, URLSpan::class.java)) {
val url = span.url val url = span.url
for ((_, url1, username) in mentions) { for ((_, url1, username) in mentions) {
if (url == url1) { if (url == url1) {
@ -149,71 +150,6 @@ data class Status(
return builder.toString() return builder.toString()
} }
/**
* overriding equals & hashcode because Spanned does not always compare correctly otherwise
*/
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Status
if (id != other.id) return false
if (url != other.url) return false
if (account != other.account) return false
if (inReplyToId != other.inReplyToId) return false
if (inReplyToAccountId != other.inReplyToAccountId) return false
if (reblog != other.reblog) return false
if (content.toString() != other.content.toString()) return false
if (createdAt != other.createdAt) return false
if (emojis != other.emojis) return false
if (reblogsCount != other.reblogsCount) return false
if (favouritesCount != other.favouritesCount) return false
if (reblogged != other.reblogged) return false
if (favourited != other.favourited) return false
if (bookmarked != other.bookmarked) return false
if (sensitive != other.sensitive) return false
if (spoilerText != other.spoilerText) return false
if (visibility != other.visibility) return false
if (attachments != other.attachments) return false
if (mentions != other.mentions) return false
if (tags != other.tags) return false
if (application != other.application) return false
if (pinned != other.pinned) return false
if (muted != other.muted) return false
if (poll != other.poll) return false
if (card != other.card) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + (url?.hashCode() ?: 0)
result = 31 * result + account.hashCode()
result = 31 * result + (inReplyToId?.hashCode() ?: 0)
result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0)
result = 31 * result + (reblog?.hashCode() ?: 0)
result = 31 * result + content.toString().hashCode()
result = 31 * result + createdAt.hashCode()
result = 31 * result + emojis.hashCode()
result = 31 * result + reblogsCount
result = 31 * result + favouritesCount
result = 31 * result + reblogged.hashCode()
result = 31 * result + favourited.hashCode()
result = 31 * result + bookmarked.hashCode()
result = 31 * result + sensitive.hashCode()
result = 31 * result + spoilerText.hashCode()
result = 31 * result + visibility.hashCode()
result = 31 * result + attachments.hashCode()
result = 31 * result + mentions.hashCode()
result = 31 * result + (tags?.hashCode() ?: 0)
result = 31 * result + (application?.hashCode() ?: 0)
result = 31 * result + (pinned?.hashCode() ?: 0)
result = 31 * result + (muted?.hashCode() ?: 0)
result = 31 * result + (poll?.hashCode() ?: 0)
result = 31 * result + (card?.hashCode() ?: 0)
return result
}
data class Mention( data class Mention(
val id: String, val id: String,
val url: String, val url: String,

View file

@ -15,6 +15,10 @@
package com.keylesspalace.tusky.fragment; package com.keylesspalace.tusky.fragment;
import static com.keylesspalace.tusky.util.StringUtils.isLessThan;
import static autodispose2.AutoDispose.autoDisposable;
import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from;
import android.app.Activity; import android.app.Activity;
import android.content.Context; import android.content.Context;
import android.content.DialogInterface; import android.content.DialogInterface;
@ -111,10 +115,6 @@ import kotlin.Unit;
import kotlin.collections.CollectionsKt; import kotlin.collections.CollectionsKt;
import kotlin.jvm.functions.Function1; import kotlin.jvm.functions.Function1;
import static autodispose2.AutoDispose.autoDisposable;
import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from;
import static com.keylesspalace.tusky.util.StringUtils.isLessThan;
public class NotificationsFragment extends SFragment implements public class NotificationsFragment extends SFragment implements
SwipeRefreshLayout.OnRefreshListener, SwipeRefreshLayout.OnRefreshListener,
StatusActionListener, StatusActionListener,
@ -707,6 +707,10 @@ public class NotificationsFragment extends SFragment implements
return getString(R.string.notification_poll_name); return getString(R.string.notification_poll_name);
case STATUS: case STATUS:
return getString(R.string.notification_subscription_name); return getString(R.string.notification_subscription_name);
case SIGN_UP:
return getString(R.string.notification_sign_up_name);
case UPDATE:
return getString(R.string.notification_update_name);
default: default:
return "Unknown"; return "Unknown";
} }

View file

@ -15,6 +15,8 @@
package com.keylesspalace.tusky.fragment; package com.keylesspalace.tusky.fragment;
import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml;
import android.Manifest; import android.Manifest;
import android.app.DownloadManager; import android.app.DownloadManager;
import android.content.ClipData; import android.content.ClipData;
@ -56,6 +58,7 @@ import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.network.TimelineCases; import com.keylesspalace.tusky.network.TimelineCases;
import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.StatusParsingHelper;
import com.keylesspalace.tusky.view.MuteAccountDialog; import com.keylesspalace.tusky.view.MuteAccountDialog;
import com.keylesspalace.tusky.viewdata.AttachmentViewData; import com.keylesspalace.tusky.viewdata.AttachmentViewData;
@ -150,7 +153,7 @@ public abstract class SFragment extends Fragment implements Injectable {
composeOptions.setContentWarning(contentWarning); composeOptions.setContentWarning(contentWarning);
composeOptions.setMentionedUsernames(mentionedUsernames); composeOptions.setMentionedUsernames(mentionedUsernames);
composeOptions.setReplyingStatusAuthor(actionableStatus.getAccount().getLocalUsername()); composeOptions.setReplyingStatusAuthor(actionableStatus.getAccount().getLocalUsername());
composeOptions.setReplyingStatusContent(actionableStatus.getContent().toString()); composeOptions.setReplyingStatusContent(parseAsMastodonHtml(actionableStatus.getContent()).toString());
Intent intent = ComposeActivity.startIntent(getContext(), composeOptions); Intent intent = ComposeActivity.startIntent(getContext(), composeOptions);
getActivity().startActivity(intent); getActivity().startActivity(intent);
@ -226,7 +229,7 @@ public abstract class SFragment extends Fragment implements Injectable {
String stringToShare = statusToShare.getAccount().getUsername() + String stringToShare = statusToShare.getAccount().getUsername() +
" - " + " - " +
statusToShare.getContent().toString(); StatusParsingHelper.parseAsMastodonHtml(statusToShare.getContent()).toString();
sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare); sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare);
sendIntent.putExtra(Intent.EXTRA_SUBJECT, statusUrl); sendIntent.putExtra(Intent.EXTRA_SUBJECT, statusUrl);
sendIntent.setType("text/plain"); sendIntent.setType("text/plain");

View file

@ -38,7 +38,7 @@ public interface StatusActionListener extends LinkListener {
void onOpenReblog(int position); void onOpenReblog(int position);
void onExpandedChange(boolean expanded, int position); void onExpandedChange(boolean expanded, int position);
void onContentHiddenChange(boolean isShowing, int position); void onContentHiddenChange(boolean isShowing, int position);
void onLoadMore(int position); void onLoadMore(int position);
/** /**
* Called when the status {@link android.widget.ToggleButton} responsible for collapsing long * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long

View file

@ -1,54 +0,0 @@
/* Copyright 2020 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.json
import android.text.Spanned
import android.text.SpannedString
import androidx.core.text.HtmlCompat
import androidx.core.text.parseAsHtml
import androidx.core.text.toHtml
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.google.gson.JsonPrimitive
import com.google.gson.JsonSerializationContext
import com.google.gson.JsonSerializer
import com.keylesspalace.tusky.util.trimTrailingWhitespace
import java.lang.reflect.Type
class SpannedTypeAdapter : JsonDeserializer<Spanned>, JsonSerializer<Spanned?> {
@Throws(JsonParseException::class)
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Spanned {
return json.asString
/* Mastodon uses 'white-space: pre-wrap;' so spaces are displayed as returned by the Api.
* We can't use CSS so we replace spaces with non-breaking-spaces to emulate the behavior.
*/
?.replace("<br> ", "<br>&nbsp;")
?.replace("<br /> ", "<br />&nbsp;")
?.replace("<br/> ", "<br/>&nbsp;")
?.replace(" ", "&nbsp;&nbsp;")
?.parseAsHtml()
/* Html.fromHtml returns trailing whitespace if the html ends in a </p> tag, which
* most status contents do, so it should be trimmed. */
?.trimTrailingWhitespace()
?: SpannedString("")
}
override fun serialize(src: Spanned?, typeOfSrc: Type, context: JsonSerializationContext): JsonElement {
return JsonPrimitive(src!!.toHtml(HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL))
}
}

View file

@ -24,7 +24,6 @@ import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.DeletedStatus
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.IdentityProof
import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.entity.Marker import com.keylesspalace.tusky.entity.Marker
import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.entity.MastoList
@ -77,10 +76,10 @@ interface MastodonApi {
fun getLists(): Single<List<MastoList>> fun getLists(): Single<List<MastoList>>
@GET("/api/v1/custom_emojis") @GET("/api/v1/custom_emojis")
fun getCustomEmojis(): Single<List<Emoji>> suspend fun getCustomEmojis(): Result<List<Emoji>>
@GET("api/v1/instance") @GET("api/v1/instance")
fun getInstance(): Single<Instance> suspend fun getInstance(): Result<Instance>
@GET("api/v1/filters") @GET("api/v1/filters")
fun getFilters(): Single<List<Filter>> fun getFilters(): Single<List<Filter>>
@ -143,27 +142,25 @@ interface MastodonApi {
@POST("api/v1/notifications/clear") @POST("api/v1/notifications/clear")
fun clearNotifications(): Single<ResponseBody> fun clearNotifications(): Single<ResponseBody>
@Multipart
@POST("api/v2/media")
fun uploadMedia(
@Part file: MultipartBody.Part,
@Part description: MultipartBody.Part? = null
): Single<MediaUploadResult>
@FormUrlEncoded @FormUrlEncoded
@PUT("api/v1/media/{mediaId}") @PUT("api/v1/media/{mediaId}")
fun updateMedia( suspend fun updateMedia(
@Path("mediaId") mediaId: String, @Path("mediaId") mediaId: String,
@Field("description") description: String @Field("description") description: String
): Single<Attachment> ): Result<Attachment>
@GET("api/v1/media/{mediaId}")
suspend fun getMedia(
@Path("mediaId") mediaId: String
): Response<MediaUploadResult>
@POST("api/v1/statuses") @POST("api/v1/statuses")
fun createStatus( suspend fun createStatus(
@Header("Authorization") auth: String, @Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String, @Header(DOMAIN_HEADER) domain: String,
@Header("Idempotency-Key") idempotencyKey: String, @Header("Idempotency-Key") idempotencyKey: String,
@Body status: NewStatus @Body status: NewStatus
): Call<Status> ): Result<Status>
@GET("api/v1/statuses/{id}") @GET("api/v1/statuses/{id}")
fun status( fun status(
@ -249,12 +246,12 @@ interface MastodonApi {
): Single<List<ScheduledStatus>> ): Single<List<ScheduledStatus>>
@DELETE("api/v1/scheduled_statuses/{id}") @DELETE("api/v1/scheduled_statuses/{id}")
fun deleteScheduledStatus( suspend fun deleteScheduledStatus(
@Path("id") scheduledStatusId: String @Path("id") scheduledStatusId: String
): Single<ResponseBody> ): Result<ResponseBody>
@GET("api/v1/accounts/verify_credentials") @GET("api/v1/accounts/verify_credentials")
fun accountVerifyCredentials(): Single<Account> suspend fun accountVerifyCredentials(): Result<Account>
@FormUrlEncoded @FormUrlEncoded
@PATCH("api/v1/accounts/update_credentials") @PATCH("api/v1/accounts/update_credentials")
@ -265,7 +262,7 @@ interface MastodonApi {
@Multipart @Multipart
@PATCH("api/v1/accounts/update_credentials") @PATCH("api/v1/accounts/update_credentials")
fun accountUpdateCredentials( suspend fun accountUpdateCredentials(
@Part(value = "display_name") displayName: RequestBody?, @Part(value = "display_name") displayName: RequestBody?,
@Part(value = "note") note: RequestBody?, @Part(value = "note") note: RequestBody?,
@Part(value = "locked") locked: RequestBody?, @Part(value = "locked") locked: RequestBody?,
@ -279,7 +276,7 @@ interface MastodonApi {
@Part(value = "fields_attributes[2][value]") fieldValue2: RequestBody?, @Part(value = "fields_attributes[2][value]") fieldValue2: RequestBody?,
@Part(value = "fields_attributes[3][name]") fieldName3: RequestBody?, @Part(value = "fields_attributes[3][name]") fieldName3: RequestBody?,
@Part(value = "fields_attributes[3][value]") fieldValue3: RequestBody? @Part(value = "fields_attributes[3][value]") fieldValue3: RequestBody?
): Call<Account> ): Result<Account>
@GET("api/v1/accounts/search") @GET("api/v1/accounts/search")
fun searchAccounts( fun searchAccounts(
@ -367,11 +364,6 @@ interface MastodonApi {
@Query("id[]") accountIds: List<String> @Query("id[]") accountIds: List<String>
): Single<List<Relationship>> ): Single<List<Relationship>>
@GET("api/v1/accounts/{id}/identity_proofs")
fun identityProofs(
@Path("id") accountId: String
): Single<List<IdentityProof>>
@POST("api/v1/pleroma/accounts/{id}/subscribe") @POST("api/v1/pleroma/accounts/{id}/subscribe")
fun subscribeAccount( fun subscribeAccount(
@Path("id") accountId: String @Path("id") accountId: String
@ -447,7 +439,7 @@ interface MastodonApi {
@Field("redirect_uris") redirectUris: String, @Field("redirect_uris") redirectUris: String,
@Field("scopes") scopes: String, @Field("scopes") scopes: String,
@Field("website") website: String @Field("website") website: String
): AppCredentials ): Result<AppCredentials>
@FormUrlEncoded @FormUrlEncoded
@POST("oauth/token") @POST("oauth/token")
@ -458,7 +450,7 @@ interface MastodonApi {
@Field("redirect_uri") redirectUri: String, @Field("redirect_uri") redirectUri: String,
@Field("code") code: String, @Field("code") code: String,
@Field("grant_type") grantType: String @Field("grant_type") grantType: String
): AccessToken ): Result<AccessToken>
@FormUrlEncoded @FormUrlEncoded
@POST("api/v1/lists") @POST("api/v1/lists")
@ -544,26 +536,26 @@ interface MastodonApi {
): Single<Poll> ): Single<Poll>
@GET("api/v1/announcements") @GET("api/v1/announcements")
fun listAnnouncements( suspend fun listAnnouncements(
@Query("with_dismissed") withDismissed: Boolean = true @Query("with_dismissed") withDismissed: Boolean = true
): Single<List<Announcement>> ): Result<List<Announcement>>
@POST("api/v1/announcements/{id}/dismiss") @POST("api/v1/announcements/{id}/dismiss")
fun dismissAnnouncement( suspend fun dismissAnnouncement(
@Path("id") announcementId: String @Path("id") announcementId: String
): Single<ResponseBody> ): Result<ResponseBody>
@PUT("api/v1/announcements/{id}/reactions/{name}") @PUT("api/v1/announcements/{id}/reactions/{name}")
fun addAnnouncementReaction( suspend fun addAnnouncementReaction(
@Path("id") announcementId: String, @Path("id") announcementId: String,
@Path("name") name: String @Path("name") name: String
): Single<ResponseBody> ): Result<ResponseBody>
@DELETE("api/v1/announcements/{id}/reactions/{name}") @DELETE("api/v1/announcements/{id}/reactions/{name}")
fun removeAnnouncementReaction( suspend fun removeAnnouncementReaction(
@Path("id") announcementId: String, @Path("id") announcementId: String,
@Path("name") name: String @Path("name") name: String
): Single<ResponseBody> ): Result<ResponseBody>
@FormUrlEncoded @FormUrlEncoded
@POST("api/v1/reports") @POST("api/v1/reports")

View file

@ -0,0 +1,19 @@
package com.keylesspalace.tusky.network
import com.keylesspalace.tusky.entity.MediaUploadResult
import okhttp3.MultipartBody
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
/** endpoints defined in this interface will be called with a higher timeout than usual
* which is necessary for media uploads to succeed on some servers
*/
interface MediaUploadApi {
@Multipart
@POST("api/v2/media")
suspend fun uploadMedia(
@Part file: MultipartBody.Part,
@Part description: MultipartBody.Part? = null
): Result<MediaUploadResult>
}

View file

@ -100,7 +100,8 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
accountId = account.id, accountId = account.id,
draftId = -1, draftId = -1,
idempotencyKey = randomAlphanumericString(16), idempotencyKey = randomAlphanumericString(16),
retries = 0 retries = 0,
mediaProcessed = mutableListOf()
) )
) )

View file

@ -11,6 +11,7 @@ import android.content.Intent
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.Parcelable import android.os.Parcelable
import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -29,13 +30,12 @@ import com.keylesspalace.tusky.network.MastodonApi
import dagger.android.AndroidInjection import dagger.android.AndroidInjection
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import retrofit2.Call import retrofit2.HttpException
import retrofit2.Callback
import retrofit2.Response
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@ -55,7 +55,7 @@ class SendStatusService : Service(), Injectable {
private val serviceScope = CoroutineScope(Dispatchers.Main + supervisorJob) private val serviceScope = CoroutineScope(Dispatchers.Main + supervisorJob)
private val statusesToSend = ConcurrentHashMap<Int, StatusToSend>() private val statusesToSend = ConcurrentHashMap<Int, StatusToSend>()
private val sendCalls = ConcurrentHashMap<Int, Call<Status>>() private val sendJobs = ConcurrentHashMap<Int, Job>()
private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager }
@ -64,12 +64,9 @@ class SendStatusService : Service(), Injectable {
super.onCreate() super.onCreate()
} }
override fun onBind(intent: Intent): IBinder? { override fun onBind(intent: Intent): IBinder? = null
return null
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
if (intent.hasExtra(KEY_STATUS)) { if (intent.hasExtra(KEY_STATUS)) {
val statusToSend = intent.getParcelableExtra<StatusToSend>(KEY_STATUS) val statusToSend = intent.getParcelableExtra<StatusToSend>(KEY_STATUS)
?: throw IllegalStateException("SendStatusService started without $KEY_STATUS extra") ?: throw IllegalStateException("SendStatusService started without $KEY_STATUS extra")
@ -129,82 +126,94 @@ class SendStatusService : Service(), Injectable {
statusToSend.retries++ statusToSend.retries++
val newStatus = NewStatus( sendJobs[statusId] = serviceScope.launch {
statusToSend.text, try {
statusToSend.warningText, var mediaCheckRetries = 0
statusToSend.inReplyToId, while (statusToSend.mediaProcessed.any { !it }) {
statusToSend.visibility, delay(1000L * mediaCheckRetries)
statusToSend.sensitive, statusToSend.mediaProcessed.forEachIndexed { index, processed ->
statusToSend.mediaIds, if (!processed) {
statusToSend.scheduledAt, // Mastodon returns 206 if the media was not yet processed
statusToSend.poll statusToSend.mediaProcessed[index] = mastodonApi.getMedia(statusToSend.mediaIds[index]).code() == 200
) }
}
mediaCheckRetries ++
}
} catch (e: Exception) {
Log.w(TAG, "failed getting media status", e)
retrySending(statusId)
return@launch
}
val sendCall = mastodonApi.createStatus( val newStatus = NewStatus(
"Bearer " + account.accessToken, statusToSend.text,
account.domain, statusToSend.warningText,
statusToSend.idempotencyKey, statusToSend.inReplyToId,
newStatus statusToSend.visibility,
) statusToSend.sensitive,
statusToSend.mediaIds,
statusToSend.scheduledAt,
statusToSend.poll
)
sendCalls[statusId] = sendCall mastodonApi.createStatus(
"Bearer " + account.accessToken,
account.domain,
statusToSend.idempotencyKey,
newStatus
).fold({ sentStatus ->
statusesToSend.remove(statusId)
// If the status was loaded from a draft, delete the draft and associated media files.
if (statusToSend.draftId != 0) {
draftHelper.deleteDraftAndAttachments(statusToSend.draftId)
}
val callback = object : Callback<Status> { val scheduled = !statusToSend.scheduledAt.isNullOrEmpty()
override fun onResponse(call: Call<Status>, response: Response<Status>) {
serviceScope.launch {
val scheduled = !statusToSend.scheduledAt.isNullOrEmpty() if (scheduled) {
eventHub.dispatch(StatusScheduledEvent(sentStatus))
} else {
eventHub.dispatch(StatusComposedEvent(sentStatus))
}
notificationManager.cancel(statusId)
}, { throwable ->
Log.w(TAG, "failed sending status", throwable)
if (throwable is HttpException) {
// the server refused to accept the status, save status & show error message
statusesToSend.remove(statusId) statusesToSend.remove(statusId)
saveStatusToDrafts(statusToSend)
if (response.isSuccessful) { val builder = NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID)
// If the status was loaded from a draft, delete the draft and associated media files. .setSmallIcon(R.drawable.ic_notify)
if (statusToSend.draftId != 0) { .setContentTitle(getString(R.string.send_post_notification_error_title))
draftHelper.deleteDraftAndAttachments(statusToSend.draftId) .setContentText(getString(R.string.send_post_notification_saved_content))
} .setColor(
ContextCompat.getColor(
if (scheduled) { this@SendStatusService,
response.body()?.let(::StatusScheduledEvent)?.let(eventHub::dispatch) R.color.notification_color
} else {
response.body()?.let(::StatusComposedEvent)?.let(eventHub::dispatch)
}
notificationManager.cancel(statusId)
} else {
// the server refused to accept the status, save status & show error message
saveStatusToDrafts(statusToSend)
val builder = NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_post_notification_error_title))
.setContentText(getString(R.string.send_post_notification_saved_content))
.setColor(
ContextCompat.getColor(
this@SendStatusService,
R.color.notification_color
)
) )
)
notificationManager.cancel(statusId) notificationManager.cancel(statusId)
notificationManager.notify(errorNotificationId--, builder.build()) notificationManager.notify(errorNotificationId--, builder.build())
} } else {
stopSelfWhenDone() // a network problem occurred, let's retry sending the status
retrySending(statusId)
} }
} })
stopSelfWhenDone()
override fun onFailure(call: Call<Status>, t: Throwable) {
serviceScope.launch {
var backoff = TimeUnit.SECONDS.toMillis(statusToSend.retries.toLong())
if (backoff > MAX_RETRY_INTERVAL) {
backoff = MAX_RETRY_INTERVAL
}
delay(backoff)
sendStatus(statusId)
}
}
} }
}
sendCall.enqueue(callback) private suspend fun retrySending(statusId: Int) {
// when statusToSend == null, sending has been canceled
val statusToSend = statusesToSend[statusId] ?: return
val backoff = TimeUnit.SECONDS.toMillis(statusToSend.retries.toLong()).coerceAtMost(MAX_RETRY_INTERVAL)
delay(backoff)
sendStatus(statusId)
} }
private fun stopSelfWhenDone() { private fun stopSelfWhenDone() {
@ -218,8 +227,8 @@ class SendStatusService : Service(), Injectable {
private fun cancelSending(statusId: Int) = serviceScope.launch { private fun cancelSending(statusId: Int) = serviceScope.launch {
val statusToCancel = statusesToSend.remove(statusId) val statusToCancel = statusesToSend.remove(statusId)
if (statusToCancel != null) { if (statusToCancel != null) {
val sendCall = sendCalls.remove(statusId) val sendJob = sendJobs.remove(statusId)
sendCall?.cancel() sendJob?.cancel()
saveStatusToDrafts(statusToCancel) saveStatusToDrafts(statusToCancel)
@ -263,6 +272,7 @@ class SendStatusService : Service(), Injectable {
} }
companion object { companion object {
private const val TAG = "SendStatusService"
private const val KEY_STATUS = "status" private const val KEY_STATUS = "status"
private const val KEY_CANCEL = "cancel_id" private const val KEY_CANCEL = "cancel_id"
@ -319,5 +329,6 @@ data class StatusToSend(
val accountId: Long, val accountId: Long,
val draftId: Int, val draftId: Int,
val idempotencyKey: String, val idempotencyKey: String,
var retries: Int var retries: Int,
val mediaProcessed: MutableList<Boolean>
) : Parcelable ) : Parcelable

View file

@ -59,6 +59,8 @@ object PrefKeys {
const val NOTIFICATION_FILTER_FOLLOW_REQUESTS = "notificationFilterFollowRequests" const val NOTIFICATION_FILTER_FOLLOW_REQUESTS = "notificationFilterFollowRequests"
const val NOTIFICATIONS_FILTER_FOLLOWS = "notificationFilterFollows" const val NOTIFICATIONS_FILTER_FOLLOWS = "notificationFilterFollows"
const val NOTIFICATION_FILTER_SUBSCRIPTIONS = "notificationFilterSubscriptions" const val NOTIFICATION_FILTER_SUBSCRIPTIONS = "notificationFilterSubscriptions"
const val NOTIFICATION_FILTER_SIGN_UPS = "notificationFilterSignUps"
const val NOTIFICATION_FILTER_UPDATES = "notificationFilterUpdates"
const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies" const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies"
const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts" const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts"

View file

@ -1,7 +1,9 @@
package com.keylesspalace.tusky.settings package com.keylesspalace.tusky.settings
import android.content.Context import android.content.Context
import androidx.activity.result.ActivityResultRegistryOwner
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.lifecycle.LifecycleOwner
import androidx.preference.CheckBoxPreference import androidx.preference.CheckBoxPreference
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
import androidx.preference.ListPreference import androidx.preference.ListPreference
@ -10,8 +12,7 @@ import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreference import androidx.preference.SwitchPreference
import com.keylesspalace.tusky.components.preference.EmojiPreference import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference
import okhttp3.OkHttpClient
class PreferenceParent( class PreferenceParent(
val context: Context, val context: Context,
@ -32,8 +33,9 @@ inline fun PreferenceParent.listPreference(builder: ListPreference.() -> Unit):
return pref return pref
} }
inline fun PreferenceParent.emojiPreference(okHttpClient: OkHttpClient, builder: EmojiPreference.() -> Unit): EmojiPreference { inline fun <A> PreferenceParent.emojiPreference(activity: A, builder: EmojiPickerPreference.() -> Unit): EmojiPickerPreference
val pref = EmojiPreference(context, okHttpClient) where A : Context, A : ActivityResultRegistryOwner, A : LifecycleOwner {
val pref = EmojiPickerPreference.get(activity)
builder(pref) builder(pref)
addPref(pref) addPref(pref)
return pref return pref

View file

@ -0,0 +1,59 @@
/* Copyright 2022 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.util
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
class AbsoluteTimeFormatter @JvmOverloads constructor(private val tz: TimeZone = TimeZone.getDefault()) {
private val sameDaySdf = SimpleDateFormat("HH:mm", Locale.getDefault()).apply { this.timeZone = tz }
private val sameYearSdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()).apply { this.timeZone = tz }
private val otherYearSdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).apply { this.timeZone = tz }
private val otherYearCompleteSdf = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).apply { this.timeZone = tz }
@JvmOverloads
fun format(time: Date?, shortFormat: Boolean = true, now: Date = Date()): String {
return when {
time == null -> "??"
isSameDate(time, now, tz) -> sameDaySdf.format(time)
isSameYear(time, now, tz) -> sameYearSdf.format(time)
shortFormat -> otherYearSdf.format(time)
else -> otherYearCompleteSdf.format(time)
}
}
companion object {
private fun isSameDate(dateOne: Date, dateTwo: Date, tz: TimeZone): Boolean {
val calendarOne = Calendar.getInstance(tz).apply { time = dateOne }
val calendarTwo = Calendar.getInstance(tz).apply { time = dateTwo }
return calendarOne.get(Calendar.YEAR) == calendarTwo.get(Calendar.YEAR) &&
calendarOne.get(Calendar.MONTH) == calendarTwo.get(Calendar.MONTH) &&
calendarOne.get(Calendar.DAY_OF_MONTH) == calendarTwo.get(Calendar.DAY_OF_MONTH)
}
private fun isSameYear(dateOne: Date, dateTwo: Date, timeZone1: TimeZone): Boolean {
val calendarOne = Calendar.getInstance(timeZone1).apply { time = dateOne }
val calendarTwo = Calendar.getInstance(timeZone1).apply { time = dateTwo }
return calendarOne.get(Calendar.YEAR) == calendarTwo.get(Calendar.YEAR)
}
}
}

View file

@ -1,364 +0,0 @@
package com.keylesspalace.tusky.util
import android.content.Context
import android.util.Log
import android.util.Pair
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import com.keylesspalace.tusky.R
import de.c1710.filemojicompat.FileEmojiCompatConfig
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.ObservableEmitter
import io.reactivex.rxjava3.schedulers.Schedulers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.internal.toLongOrDefault
import okio.Source
import okio.buffer
import okio.sink
import java.io.EOFException
import java.io.File
import java.io.FilenameFilter
import java.io.IOException
import kotlin.math.max
/**
* This class bundles information about an emoji font as well as many convenient actions.
*/
class EmojiCompatFont(
val name: String,
private val display: String,
@StringRes val caption: Int,
@DrawableRes val img: Int,
val url: String,
// The version is stored as a String in the x.xx.xx format (to be able to compare versions)
val version: String
) {
private val versionCode = getVersionCode(version)
// A list of all available font files and whether they are older than the current version or not
// They are ordered by their version codes in ascending order
private var existingFontFileCache: List<Pair<File, List<Int>>>? = null
val id: Int
get() = FONTS.indexOf(this)
fun getDisplay(context: Context): String {
return if (this !== SYSTEM_DEFAULT) display else context.getString(R.string.system_default)
}
/**
* This method will return the actual font file (regardless of its existence) for
* the current version (not necessarily the latest!).
*
* @return The font (TTF) file or null if called on SYSTEM_FONT
*/
private fun getFontFile(context: Context): File? {
return if (this !== SYSTEM_DEFAULT) {
val directory = File(context.getExternalFilesDir(null), DIRECTORY)
File(directory, "$name$version.ttf")
} else {
null
}
}
fun getConfig(context: Context): FileEmojiCompatConfig {
return FileEmojiCompatConfig(context, getLatestFontFile(context))
}
fun isDownloaded(context: Context): Boolean {
return this === SYSTEM_DEFAULT || getFontFile(context)?.exists() == true || fontFileExists(context)
}
/**
* Checks whether there is already a font version that satisfies the current version, i.e. it
* has a higher or equal version code.
*
* @param context The Context
* @return Whether there is a font file with a higher or equal version code to the current
*/
private fun fontFileExists(context: Context): Boolean {
val existingFontFiles = getExistingFontFiles(context)
return if (existingFontFiles.isNotEmpty()) {
compareVersions(existingFontFiles.last().second, versionCode) >= 0
} else {
false
}
}
/**
* Deletes any older version of a font
*
* @param context The current Context
*/
private fun deleteOldVersions(context: Context) {
val existingFontFiles = getExistingFontFiles(context)
Log.d(TAG, "deleting old versions...")
Log.d(TAG, String.format("deleteOldVersions: Found %d other font files", existingFontFiles.size))
for (fileExists in existingFontFiles) {
if (compareVersions(fileExists.second, versionCode) < 0) {
val file = fileExists.first
// Uses side effects!
Log.d(
TAG,
String.format(
"Deleted %s successfully: %s", file.absolutePath,
file.delete()
)
)
}
}
}
/**
* Loads all font files that are inside the files directory into an ArrayList with the information
* on whether they are older than the currently available version or not.
*
* @param context The Context
*/
private fun getExistingFontFiles(context: Context): List<Pair<File, List<Int>>> {
// Only load it once
existingFontFileCache?.let {
return it
}
// If we call this on the system default font, just return nothing...
if (this === SYSTEM_DEFAULT) {
existingFontFileCache = emptyList()
return emptyList()
}
val directory = File(context.getExternalFilesDir(null), DIRECTORY)
// It will search for old versions using a regex that matches the font's name plus
// (if present) a version code. No version code will be regarded as version 0.
val fontRegex = "$name(\\d+(\\.\\d+)*)?\\.ttf".toPattern()
val ttfFilter = FilenameFilter { _, name: String -> name.endsWith(".ttf") }
val foundFontFiles = directory.listFiles(ttfFilter).orEmpty()
Log.d(
TAG,
String.format(
"loadExistingFontFiles: %d other font files found",
foundFontFiles.size
)
)
return foundFontFiles.map { file ->
val matcher = fontRegex.matcher(file.name)
val versionCode = if (matcher.matches()) {
val version = matcher.group(1)
getVersionCode(version)
} else {
listOf(0)
}
Pair(file, versionCode)
}.sortedWith { a, b ->
compareVersions(a.second, b.second)
}.also {
existingFontFileCache = it
}
}
/**
* Returns the current or latest version of this font file (if there is any)
*
* @param context The Context
* @return The file for this font with the current or (if not existent) highest version code or null if there is no file for this font.
*/
private fun getLatestFontFile(context: Context): File? {
val current = getFontFile(context)
if (current != null && current.exists()) return current
val existingFontFiles = getExistingFontFiles(context)
return existingFontFiles.firstOrNull()?.first
}
private fun getVersionCode(version: String?): List<Int> {
if (version == null) return listOf(0)
return version.split(".").map {
it.toIntOrNull() ?: 0
}
}
fun downloadFontFile(
context: Context,
okHttpClient: OkHttpClient
): Observable<Float> {
return Observable.create { emitter: ObservableEmitter<Float> ->
// It is possible (and very likely) that the file does not exist yet
val downloadFile = getFontFile(context)!!
if (!downloadFile.exists()) {
downloadFile.parentFile?.mkdirs()
downloadFile.createNewFile()
}
val request = Request.Builder().url(url)
.build()
val sink = downloadFile.sink().buffer()
var source: Source? = null
try {
// Download!
val response = okHttpClient.newCall(request).execute()
val responseBody = response.body
if (response.isSuccessful && responseBody != null) {
val size = response.length()
var progress = 0f
source = responseBody.source()
try {
while (!emitter.isDisposed) {
sink.write(source, CHUNK_SIZE)
progress += CHUNK_SIZE.toFloat()
if (size > 0) {
emitter.onNext(progress / size)
} else {
emitter.onNext(-1f)
}
}
} catch (ex: EOFException) {
/*
This means we've finished downloading the file since sink.write
will throw an EOFException when the file to be read is empty.
*/
}
} else {
Log.e(TAG, "Downloading $url failed. Status code: ${response.code}")
emitter.tryOnError(Exception())
}
} catch (ex: IOException) {
Log.e(TAG, "Downloading $url failed.", ex)
downloadFile.deleteIfExists()
emitter.tryOnError(ex)
} finally {
source?.close()
sink.close()
if (emitter.isDisposed) {
downloadFile.deleteIfExists()
} else {
deleteOldVersions(context)
emitter.onComplete()
}
}
}
.subscribeOn(Schedulers.io())
}
/**
* Deletes the downloaded file, if it exists. Should be called when a download gets cancelled.
*/
fun deleteDownloadedFile(context: Context) {
getFontFile(context)?.deleteIfExists()
}
override fun toString(): String {
return display
}
companion object {
private const val TAG = "EmojiCompatFont"
/**
* This String represents the sub-directory the fonts are stored in.
*/
private const val DIRECTORY = "emoji"
private const val CHUNK_SIZE = 4096L
// The system font gets some special behavior...
val SYSTEM_DEFAULT = EmojiCompatFont(
"system-default",
"System Default",
R.string.caption_systememoji,
R.drawable.ic_emoji_34dp,
"",
"0"
)
val BLOBMOJI = EmojiCompatFont(
"Blobmoji",
"Blobmoji",
R.string.caption_blobmoji,
R.drawable.ic_blobmoji,
"https://tusky.app/hosted/emoji/BlobmojiCompat.ttf",
"14.0.1"
)
val TWEMOJI = EmojiCompatFont(
"Twemoji",
"Twemoji",
R.string.caption_twemoji,
R.drawable.ic_twemoji,
"https://tusky.app/hosted/emoji/TwemojiCompat.ttf",
"14.0.0"
)
val NOTOEMOJI = EmojiCompatFont(
"NotoEmoji",
"Noto Emoji",
R.string.caption_notoemoji,
R.drawable.ic_notoemoji,
"https://tusky.app/hosted/emoji/NotoEmojiCompat.ttf",
"14.0.0"
)
/**
* This array stores all available EmojiCompat fonts.
* References to them can simply be saved by saving their indices
*/
val FONTS = listOf(SYSTEM_DEFAULT, BLOBMOJI, TWEMOJI, NOTOEMOJI)
/**
* Returns the Emoji font associated with this ID
*
* @param id the ID of this font
* @return the corresponding font. Will default to SYSTEM_DEFAULT if not in range.
*/
fun byId(id: Int): EmojiCompatFont = FONTS.getOrElse(id) { SYSTEM_DEFAULT }
/**
* Compares two version codes to each other
*
* @param versionA The first version
* @param versionB The second version
* @return -1 if versionA < versionB, 1 if versionA > versionB and 0 otherwise
*/
@VisibleForTesting
fun compareVersions(versionA: List<Int>, versionB: List<Int>): Int {
val len = max(versionB.size, versionA.size)
for (i in 0 until len) {
val vA = versionA.getOrElse(i) { 0 }
val vB = versionB.getOrElse(i) { 0 }
// It needs to be decided on the next level
if (vA == vB) continue
// Okay, is version B newer or version A?
return vA.compareTo(vB)
}
// The versions are equal
return 0
}
/**
* This method is needed because when transparent compression is used OkHttp reports
* [ResponseBody.contentLength] as -1. We try to get the header which server sent
* us manually here.
*
* @see [OkHttp issue 259](https://github.com/square/okhttp/issues/259)
*/
private fun Response.length(): Long {
networkResponse?.let {
val header = it.header("Content-Length") ?: return -1
return header.toLongOrDefault(-1)
}
// In case it's a fully cached response
return body?.contentLength() ?: -1
}
private fun File.deleteIfExists() {
if (exists() && !delete()) {
Log.e(TAG, "Could not delete file $this")
}
}
}
}

View file

@ -0,0 +1,63 @@
/* Copyright 2022 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
@file:JvmName("StatusParsingHelper")
package com.keylesspalace.tusky.util
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.core.text.parseAsHtml
/**
* parse a String containing html from the Mastodon api to Spanned
*/
fun String.parseAsMastodonHtml(): Spanned {
return this.replace("<br> ", "<br>&nbsp;")
.replace("<br /> ", "<br />&nbsp;")
.replace("<br/> ", "<br/>&nbsp;")
.replace(" ", "&nbsp;&nbsp;")
.parseAsHtml()
/* Html.fromHtml returns trailing whitespace if the html ends in a </p> tag, which
* most status contents do, so it should be trimmed. */
.trimTrailingWhitespace()
}
fun replaceCrashingCharacters(content: Spanned): Spanned {
return replaceCrashingCharacters(content as CharSequence) as Spanned
}
fun replaceCrashingCharacters(content: CharSequence): CharSequence? {
var replacing = false
var builder: SpannableStringBuilder? = null
val length = content.length
for (index in 0 until length) {
val character = content[index]
// If there are more than one or two, switch to a map
if (character == SOFT_HYPHEN) {
if (!replacing) {
replacing = true
builder = SpannableStringBuilder(content, 0, index)
}
builder!!.append(ASCII_HYPHEN)
} else if (replacing) {
builder!!.append(character)
}
}
return if (replacing) builder else content
}
private const val SOFT_HYPHEN = '\u00ad'
private const val ASCII_HYPHEN = '-'

View file

@ -34,20 +34,16 @@ import com.keylesspalace.tusky.viewdata.PollViewData
import com.keylesspalace.tusky.viewdata.buildDescription import com.keylesspalace.tusky.viewdata.buildDescription
import com.keylesspalace.tusky.viewdata.calculatePercent import com.keylesspalace.tusky.viewdata.calculatePercent
import java.text.NumberFormat import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.min import kotlin.math.min
class StatusViewHelper(private val itemView: View) { class StatusViewHelper(private val itemView: View) {
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
interface MediaPreviewListener { interface MediaPreviewListener {
fun onViewMedia(v: View?, idx: Int) fun onViewMedia(v: View?, idx: Int)
fun onContentHiddenChange(isShowing: Boolean) fun onContentHiddenChange(isShowing: Boolean)
} }
private val shortSdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
private val longSdf = SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault())
fun setMediasPreview( fun setMediasPreview(
statusDisplayOptions: StatusDisplayOptions, statusDisplayOptions: StatusDisplayOptions,
attachments: List<Attachment>, attachments: List<Attachment>,
@ -295,7 +291,7 @@ class StatusViewHelper(private val itemView: View) {
context.getString(R.string.poll_info_closed) context.getString(R.string.poll_info_closed)
} else { } else {
if (useAbsoluteTime) { if (useAbsoluteTime) {
context.getString(R.string.poll_info_time_absolute, getAbsoluteTime(poll.expiresAt)) context.getString(R.string.poll_info_time_absolute, absoluteTimeFormatter.format(poll.expiresAt, false))
} else { } else {
TimestampUtils.formatPollDuration(context, poll.expiresAt!!.time, timestamp) TimestampUtils.formatPollDuration(context, poll.expiresAt!!.time, timestamp)
} }
@ -330,18 +326,6 @@ class StatusViewHelper(private val itemView: View) {
} }
} }
fun getAbsoluteTime(time: Date?): String {
return if (time != null) {
if (android.text.format.DateUtils.isToday(time.time)) {
shortSdf.format(time)
} else {
longSdf.format(time)
}
} else {
"??:??:??"
}
}
companion object { companion object {
val COLLAPSE_INPUT_FILTER = arrayOf<InputFilter>(SmartLengthInputFilter) val COLLAPSE_INPUT_FILTER = arrayOf<InputFilter>(SmartLengthInputFilter)
val NO_INPUT_FILTER = arrayOfNulls<InputFilter>(0) val NO_INPUT_FILTER = arrayOfNulls<InputFilter>(0)

View file

@ -1,44 +0,0 @@
/* Copyright 2019 kyori19
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.util;
import androidx.annotation.NonNull;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class VersionUtils {
private int major;
private int minor;
private int patch;
public VersionUtils(@NonNull String versionString) {
String regex = "([0-9]+)\\.([0-9]+)\\.([0-9]+).*";
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(versionString);
if (matcher.find()) {
major = Integer.parseInt(matcher.group(1));
minor = Integer.parseInt(matcher.group(2));
patch = Integer.parseInt(matcher.group(3));
}
}
public boolean supportsScheduledToots() {
return (major == 2) ? ( (minor == 7) ? (patch >= 0) : (minor > 7) ) : (major > 2);
}
}

View file

@ -27,12 +27,9 @@ fun Status.toViewData(
isExpanded: Boolean, isExpanded: Boolean,
isCollapsed: Boolean isCollapsed: Boolean
): StatusViewData.Concrete { ): StatusViewData.Concrete {
val visibleStatus = this.reblog ?: this
return StatusViewData.Concrete( return StatusViewData.Concrete(
status = this, status = this,
isShowingContent = isShowingContent, isShowingContent = isShowingContent,
isCollapsible = shouldTrimStatus(visibleStatus.content),
isCollapsed = isCollapsed, isCollapsed = isCollapsed,
isExpanded = isExpanded, isExpanded = isExpanded,
) )

View file

@ -15,9 +15,11 @@
package com.keylesspalace.tusky.viewdata package com.keylesspalace.tusky.viewdata
import android.os.Build import android.os.Build
import android.text.SpannableStringBuilder
import android.text.Spanned import android.text.Spanned
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.replaceCrashingCharacters
import com.keylesspalace.tusky.util.shouldTrimStatus
/** /**
* Created by charlag on 11/07/2017. * Created by charlag on 11/07/2017.
@ -32,13 +34,6 @@ sealed class StatusViewData {
val status: Status, val status: Status,
val isExpanded: Boolean, val isExpanded: Boolean,
val isShowingContent: Boolean, val isShowingContent: Boolean,
/**
* Specifies whether the content of this post is allowed to be collapsed or if it should show
* all content regardless.
*
* @return Whether the post is collapsible or never collapsed.
*/
val isCollapsible: Boolean,
/** /**
* Specifies whether the content of this post is currently limited in visibility to the first * Specifies whether the content of this post is currently limited in visibility to the first
* 500 characters or not. * 500 characters or not.
@ -51,6 +46,14 @@ sealed class StatusViewData {
override val id: String override val id: String
get() = status.id get() = status.id
/**
* Specifies whether the content of this post is allowed to be collapsed or if it should show
* all content regardless.
*
* @return Whether the post is collapsible or never collapsed.
*/
val isCollapsible: Boolean
val content: Spanned val content: Spanned
val spoilerText: String val spoilerText: String
val username: String val username: String
@ -74,45 +77,17 @@ sealed class StatusViewData {
init { init {
if (Build.VERSION.SDK_INT == 23) { if (Build.VERSION.SDK_INT == 23) {
// https://github.com/tuskyapp/Tusky/issues/563 // https://github.com/tuskyapp/Tusky/issues/563
this.content = replaceCrashingCharacters(status.actionableStatus.content) this.content = replaceCrashingCharacters(status.actionableStatus.content.parseAsMastodonHtml())
this.spoilerText = this.spoilerText =
replaceCrashingCharacters(status.actionableStatus.spoilerText).toString() replaceCrashingCharacters(status.actionableStatus.spoilerText).toString()
this.username = this.username =
replaceCrashingCharacters(status.actionableStatus.account.username).toString() replaceCrashingCharacters(status.actionableStatus.account.username).toString()
} else { } else {
this.content = status.actionableStatus.content this.content = status.actionableStatus.content.parseAsMastodonHtml()
this.spoilerText = status.actionableStatus.spoilerText this.spoilerText = status.actionableStatus.spoilerText
this.username = status.actionableStatus.account.username this.username = status.actionableStatus.account.username
} }
} this.isCollapsible = shouldTrimStatus(this.content)
companion object {
private const val SOFT_HYPHEN = '\u00ad'
private const val ASCII_HYPHEN = '-'
fun replaceCrashingCharacters(content: Spanned): Spanned {
return replaceCrashingCharacters(content as CharSequence) as Spanned
}
fun replaceCrashingCharacters(content: CharSequence): CharSequence? {
var replacing = false
var builder: SpannableStringBuilder? = null
val length = content.length
for (index in 0 until length) {
val character = content[index]
// If there are more than one or two, switch to a map
if (character == SOFT_HYPHEN) {
if (!replacing) {
replacing = true
builder = SpannableStringBuilder(content, 0, index)
}
builder!!.append(ASCII_HYPHEN)
} else if (replacing) {
builder!!.append(character)
}
}
return if (replacing) builder else content
}
} }
/** Helper for Java */ /** Helper for Java */

View file

@ -20,6 +20,7 @@ import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.appstore.ProfileEditedEvent
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
@ -31,8 +32,7 @@ import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.randomAlphanumericString import com.keylesspalace.tusky.util.randomAlphanumericString
import io.reactivex.rxjava3.disposables.CompositeDisposable import kotlinx.coroutines.launch
import io.reactivex.rxjava3.kotlin.addTo
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
@ -40,9 +40,7 @@ import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import retrofit2.Call import retrofit2.HttpException
import retrofit2.Callback
import retrofit2.Response
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
@ -63,24 +61,20 @@ class EditProfileViewModel @Inject constructor(
private var oldProfileData: Account? = null private var oldProfileData: Account? = null
private val disposables = CompositeDisposable() fun obtainProfile() = viewModelScope.launch {
fun obtainProfile() {
if (profileData.value == null || profileData.value is Error) { if (profileData.value == null || profileData.value is Error) {
profileData.postValue(Loading()) profileData.postValue(Loading())
mastodonApi.accountVerifyCredentials() mastodonApi.accountVerifyCredentials().fold(
.subscribe( { profile ->
{ profile -> oldProfileData = profile
oldProfileData = profile profileData.postValue(Success(profile))
profileData.postValue(Success(profile)) },
}, {
{ profileData.postValue(Error())
profileData.postValue(Error()) }
} )
)
.addTo(disposables)
} }
} }
@ -151,34 +145,34 @@ class EditProfileViewModel @Inject constructor(
return return
} }
mastodonApi.accountUpdateCredentials( viewModelScope.launch {
displayName, note, locked, avatar, header, mastodonApi.accountUpdateCredentials(
field1?.first, field1?.second, field2?.first, field2?.second, field3?.first, field3?.second, field4?.first, field4?.second displayName, note, locked, avatar, header,
).enqueue(object : Callback<Account> { field1?.first, field1?.second, field2?.first, field2?.second, field3?.first, field3?.second, field4?.first, field4?.second
override fun onResponse(call: Call<Account>, response: Response<Account>) { ).fold(
val newProfileData = response.body() { newProfileData ->
if (!response.isSuccessful || newProfileData == null) { saveData.postValue(Success())
val errorResponse = response.errorBody()?.string() eventHub.dispatch(ProfileEditedEvent(newProfileData))
val errorMsg = if (!errorResponse.isNullOrBlank()) { },
try { { throwable ->
JSONObject(errorResponse).optString("error", null) if (throwable is HttpException) {
} catch (e: JSONException) { val errorResponse = throwable.response()?.errorBody()?.string()
val errorMsg = if (!errorResponse.isNullOrBlank()) {
try {
JSONObject(errorResponse).optString("error", "")
} catch (e: JSONException) {
null
}
} else {
null null
} }
saveData.postValue(Error(errorMessage = errorMsg))
} else { } else {
null saveData.postValue(Error())
} }
saveData.postValue(Error(errorMessage = errorMsg))
return
} }
saveData.postValue(Success()) )
eventHub.dispatch(ProfileEditedEvent(newProfileData)) }
}
override fun onFailure(call: Call<Account>, t: Throwable) {
saveData.postValue(Error())
}
})
} }
// cache activity state for rotation change // cache activity state for rotation change
@ -208,15 +202,11 @@ class EditProfileViewModel @Inject constructor(
return File(application.cacheDir, filename) return File(application.cacheDir, filename)
} }
override fun onCleared() { fun obtainInstance() = viewModelScope.launch {
disposables.dispose()
}
fun obtainInstance() {
if (instanceData.value == null || instanceData.value is Error) { if (instanceData.value == null || instanceData.value is Error) {
instanceData.postValue(Loading()) instanceData.postValue(Loading())
mastodonApi.getInstance().subscribe( mastodonApi.getInstance().fold(
{ instance -> { instance ->
instanceData.postValue(Success(instance)) instanceData.postValue(Success(instance))
}, },
@ -224,7 +214,6 @@ class EditProfileViewModel @Inject constructor(
instanceData.postValue(Error()) instanceData.postValue(Error())
} }
) )
.addTo(disposables)
} }
} }
} }

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M5.7407,0L18.2593,0A5.7407,5.7407 0,0 1,24 5.7407L24,18.2593A5.7407,5.7407 0,0 1,18.2593 24L5.7407,24A5.7407,5.7407 0,0 1,0 18.2593L0,5.7407A5.7407,5.7407 0,0 1,5.7407 0z"
android:fillAlpha="0.75"
android:fillColor="@color/botBadgeBackground" />
<path
android:fillColor="@color/botBadgeForeground"
android:pathData="m12,3.1674a1.6059,1.6059 0,0 1,1.6059 1.6059c0,0.5942 -0.3212,1.1161 -0.803,1.3891v1.0198h0.803a5.6207,5.6207 0,0 1,5.6207 5.6207h0.803a0.803,0.803 0,0 1,0.803 0.803v2.4089a0.803,0.803 0,0 1,-0.803 0.803h-0.803v0.803a1.6059,1.6059 0,0 1,-1.6059 1.6059H6.3793A1.6059,1.6059 0,0 1,4.7733 17.6207V16.8178H3.9704A0.803,0.803 0,0 1,3.1674 16.0148V13.6059A0.803,0.803 0,0 1,3.9704 12.803H4.7733a5.6207,5.6207 0,0 1,5.6207 -5.6207h0.803V6.1625C10.7153,5.8894 10.3941,5.3675 10.3941,4.7733A1.6059,1.6059 0,0 1,12 3.1674M8.3867,12A2.0074,2.0074 0,0 0,6.3793 14.0074,2.0074 2.0074,0 0,0 8.3867,16.0148 2.0074,2.0074 0,0 0,10.3941 14.0074,2.0074 2.0074,0 0,0 8.3867,12m7.2267,0a2.0074,2.0074 0,0 0,-2.0074 2.0074,2.0074 2.0074,0 0,0 2.0074,2.0074 2.0074,2.0074 0,0 0,2.0074 -2.0074A2.0074,2.0074 0,0 0,15.6133 12Z" />
</vector>

View file

@ -4,6 +4,6 @@
android:viewportHeight="24" android:viewportHeight="24"
android:viewportWidth="24"> android:viewportWidth="24">
<path <path
android:fillColor="#000" android:fillColor="@color/textColorTertiary"
android:pathData="M20,6C20.58,6 21.05,6.2 21.42,6.59C21.8,7 22,7.45 22,8V19C22,19.55 21.8,20 21.42,20.41C21.05,20.8 20.58,21 20,21H4C3.42,21 2.95,20.8 2.58,20.41C2.2,20 2,19.55 2,19V8C2,7.45 2.2,7 2.58,6.59C2.95,6.2 3.42,6 4,6H8V4C8,3.42 8.2,2.95 8.58,2.58C8.95,2.2 9.42,2 10,2H14C14.58,2 15.05,2.2 15.42,2.58C15.8,2.95 16,3.42 16,4V6H20M4,8V19H20V8H4M14,6V4H10V6H14Z" /> android:pathData="M20,6C20.58,6 21.05,6.2 21.42,6.59C21.8,7 22,7.45 22,8V19C22,19.55 21.8,20 21.42,20.41C21.05,20.8 20.58,21 20,21H4C3.42,21 2.95,20.8 2.58,20.41C2.2,20 2,19.55 2,19V8C2,7.45 2.2,7 2.58,6.59C2.95,6.2 3.42,6 4,6H8V4C8,3.42 8.2,2.95 8.58,2.58C8.95,2.2 9.42,2 10,2H14C14.58,2 15.05,2.2 15.42,2.58C15.8,2.95 16,3.42 16,4V6H20M4,8V19H20V8H4M14,6V4H10V6H14Z" />
</vector> </vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
</vector>

View file

@ -112,7 +112,7 @@
app:layout_constraintStart_toStartOf="@id/guideAvatar" app:layout_constraintStart_toStartOf="@id/guideAvatar"
app:layout_constraintTop_toTopOf="@+id/accountFollowButton" /> app:layout_constraintTop_toTopOf="@+id/accountFollowButton" />
<androidx.emoji.widget.EmojiTextView <TextView
android:id="@+id/accountDisplayNameTextView" android:id="@+id/accountDisplayNameTextView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -215,7 +215,7 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountNoteTextInputLayout" /> app:layout_constraintTop_toBottomOf="@id/accountNoteTextInputLayout" />
<androidx.emoji.widget.EmojiTextView <TextView
android:id="@+id/accountNoteTextView" android:id="@+id/accountNoteTextView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -248,63 +248,71 @@
app:layout_constraintTop_toBottomOf="@id/accountFieldList" app:layout_constraintTop_toBottomOf="@id/accountFieldList"
tools:visibility="visible" /> tools:visibility="visible" />
<androidx.constraintlayout.widget.Group <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/accountMovedView" android:id="@+id/accountMovedView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:visibility="gone" android:visibility="gone"
app:constraint_referenced_ids="accountMovedText,accountMovedAvatar,accountMovedDisplayName,accountMovedUsername" /> android:layout_marginTop="4dp"
<androidx.emoji.widget.EmojiTextView
android:id="@+id/accountMovedText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:drawablePadding="6dp"
android:textSize="?attr/status_text_medium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountRemoveView" app:layout_constraintTop_toBottomOf="@id/accountRemoveView"
tools:text="Account has moved" /> tools:visibility="visible">
<ImageView <TextView
android:id="@+id/accountMovedAvatar" android:id="@+id/accountMovedText"
android:layout_width="48dp" android:layout_width="wrap_content"
android:layout_height="48dp" android:layout_height="wrap_content"
android:layout_centerVertical="true" android:layout_marginTop="8dp"
android:layout_marginTop="8dp" android:drawablePadding="6dp"
android:layout_marginEnd="24dp" android:drawableStart="@drawable/ic_briefcase"
app:layout_constraintStart_toStartOf="parent" android:textSize="?attr/status_text_medium"
app:layout_constraintTop_toBottomOf="@id/accountMovedText" app:layout_constraintStart_toStartOf="parent"
tools:src="@drawable/avatar_default" /> app:layout_constraintTop_toTopOf="parent"
tools:text="Account has moved" />
<androidx.emoji.widget.EmojiTextView <ImageView
android:id="@+id/accountMovedDisplayName" android:importantForAccessibility="no"
android:layout_width="wrap_content" android:id="@+id/accountMovedAvatar"
android:layout_height="wrap_content" android:layout_width="48dp"
android:layout_marginStart="16dp" android:layout_height="48dp"
android:ellipsize="end" android:layout_centerVertical="true"
android:maxLines="1" android:layout_marginTop="8dp"
android:textColor="?android:textColorPrimary" android:layout_marginBottom="8dp"
android:textSize="?attr/status_text_large" android:layout_marginEnd="24dp"
android:textStyle="normal|bold" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@id/accountMovedUsername" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/accountMovedAvatar" app:layout_constraintTop_toBottomOf="@id/accountMovedText"
app:layout_constraintTop_toTopOf="@id/accountMovedAvatar" tools:src="@drawable/avatar_default" />
tools:text="Display name" />
<TextView <TextView
android:id="@+id/accountMovedUsername" android:id="@+id/accountMovedDisplayName"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="16dp" android:layout_marginStart="16dp"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="1" android:maxLines="1"
android:textColor="?android:textColorSecondary" android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_medium" android:textSize="?attr/status_text_large"
app:layout_constraintBottom_toBottomOf="@id/accountMovedAvatar" android:textStyle="normal|bold"
app:layout_constraintStart_toEndOf="@id/accountMovedAvatar" app:layout_constraintBottom_toTopOf="@id/accountMovedUsername"
app:layout_constraintTop_toBottomOf="@id/accountMovedDisplayName" app:layout_constraintStart_toEndOf="@id/accountMovedAvatar"
tools:text="\@username" /> app:layout_constraintTop_toTopOf="@id/accountMovedAvatar"
tools:text="Display name" />
<TextView
android:id="@+id/accountMovedUsername"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
app:layout_constraintBottom_toBottomOf="@id/accountMovedAvatar"
app:layout_constraintStart_toEndOf="@id/accountMovedAvatar"
app:layout_constraintTop_toBottomOf="@id/accountMovedDisplayName"
tools:text="\@username" />
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout <LinearLayout
android:id="@+id/accountStatuses" android:id="@+id/accountStatuses"
@ -315,7 +323,7 @@
android:orientation="vertical" android:orientation="vertical"
app:layout_constraintEnd_toStartOf="@id/accountFollowing" app:layout_constraintEnd_toStartOf="@id/accountFollowing"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountMovedAvatar"> app:layout_constraintTop_toBottomOf="@id/accountMovedView">
<TextView <TextView
android:id="@+id/accountStatusesTextView" android:id="@+id/accountStatusesTextView"
@ -346,7 +354,7 @@
android:orientation="vertical" android:orientation="vertical"
app:layout_constraintEnd_toStartOf="@id/accountFollowers" app:layout_constraintEnd_toStartOf="@id/accountFollowers"
app:layout_constraintStart_toEndOf="@id/accountStatuses" app:layout_constraintStart_toEndOf="@id/accountStatuses"
app:layout_constraintTop_toBottomOf="@id/accountMovedAvatar"> app:layout_constraintTop_toBottomOf="@id/accountMovedView">
<TextView <TextView
android:id="@+id/accountFollowingTextView" android:id="@+id/accountFollowingTextView"
@ -376,7 +384,7 @@
android:orientation="vertical" android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/accountFollowing" app:layout_constraintStart_toEndOf="@id/accountFollowing"
app:layout_constraintTop_toBottomOf="@id/accountMovedAvatar"> app:layout_constraintTop_toBottomOf="@id/accountMovedView">
<TextView <TextView
android:id="@+id/accountFollowersTextView" android:id="@+id/accountFollowersTextView"

View file

@ -72,7 +72,7 @@
tools:text="Reply to @username" tools:text="Reply to @username"
tools:visibility="visible" /> tools:visibility="visible" />
<androidx.emoji.widget.EmojiTextView <TextView
android:id="@+id/composeReplyContentView" android:id="@+id/composeReplyContentView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -94,7 +94,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical"> android:orientation="vertical">
<androidx.emoji.widget.EmojiEditText <androidx.emoji2.widget.EmojiEditText
android:id="@+id/composeContentWarningField" android:id="@+id/composeContentWarningField"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View file

@ -11,11 +11,16 @@
<androidx.appcompat.widget.Toolbar <androidx.appcompat.widget.Toolbar
android:id="@+id/loginToolbar" android:id="@+id/loginToolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content" />
app:titleTextColor="#fff" />
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>
<ProgressBar
android:id="@+id/loginProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
<WebView <WebView
android:id="@+id/loginWebView" android:id="@+id/loginWebView"
android:layout_width="match_parent" android:layout_width="match_parent"

View file

@ -58,6 +58,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" android:layout_height="?attr/actionBarSize"
app:tabIndicator="@null" app:tabIndicator="@null"
app:tabGravity="fill"
app:tabMode="fixed" /> app:tabMode="fixed" />
</com.google.android.material.bottomappbar.BottomAppBar> </com.google.android.material.bottomappbar.BottomAppBar>

View file

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingTop="16dp">
<include
android:id="@+id/item_blobmoji"
layout="@layout/item_emoji_pref" />
<include
android:id="@+id/item_twemoji"
layout="@layout/item_emoji_pref" />
<include
android:id="@+id/item_notoemoji"
layout="@layout/item_emoji_pref" />
<include
android:id="@+id/item_nomoji"
layout="@layout/item_emoji_pref" />
<TextView
android:id="@+id/emoji_download_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lineSpacingMultiplier="1.1"
android:paddingStart="24dp"
android:paddingTop="16dp"
android:paddingEnd="24dp"
android:paddingBottom="8dp"
android:text="@string/download_fonts"
android:textColor="?android:attr/textColorSecondary" />
</LinearLayout>

View file

@ -32,7 +32,7 @@
tools:src="#000" tools:src="#000"
tools:visibility="visible" /> tools:visibility="visible" />
<androidx.emoji.widget.EmojiTextView <TextView
android:id="@+id/account_display_name" android:id="@+id/account_display_name"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View file

@ -7,7 +7,7 @@
android:paddingTop="4dp"> android:paddingTop="4dp">
<!-- 30% width for the field name, 70% for the value --> <!-- 30% width for the field name, 70% for the value -->
<androidx.emoji.widget.EmojiTextView <TextView
android:id="@+id/accountFieldName" android:id="@+id/accountFieldName"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -21,7 +21,7 @@
app:layout_constraintWidth_percent=".3" app:layout_constraintWidth_percent=".3"
tools:text="Field title" /> tools:text="Field title" />
<androidx.emoji.widget.EmojiTextView <TextView
android:id="@+id/accountFieldValue" android:id="@+id/accountFieldValue"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View file

@ -4,7 +4,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<androidx.emoji.widget.EmojiTextView <TextView
android:id="@+id/text" android:id="@+id/text"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View file

@ -22,7 +22,7 @@
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="vertical"> android:orientation="vertical">
<androidx.emoji.widget.EmojiTextView <TextView
android:id="@+id/display_name" android:id="@+id/display_name"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View file

@ -38,7 +38,7 @@
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="vertical"> android:orientation="vertical">
<androidx.emoji.widget.EmojiTextView <TextView
android:id="@+id/blocked_user_display_name" android:id="@+id/blocked_user_display_name"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"

Some files were not shown because too many files have changed in this diff Show more