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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -20,6 +20,8 @@ import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.ContextCompat;
import androidx.core.text.HtmlCompat;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager;
@ -27,6 +29,7 @@ import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
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.GranularRoundedCorners;
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.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
@ -54,10 +58,8 @@ import com.keylesspalace.tusky.viewdata.PollViewDataKt;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.text.NumberFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import at.connyduck.sparkbutton.SparkButton;
import at.connyduck.sparkbutton.helpers.Utils;
@ -77,6 +79,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private SparkButton favouriteButton;
private SparkButton bookmarkButton;
private ImageButton moreButton;
private ConstraintLayout mediaContainer;
protected MediaPreviewImageView[] mediaPreviews;
private ImageView[] mediaOverlays;
private TextView sensitiveMediaWarning;
@ -103,10 +106,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private TextView cardUrl;
private PollAdapter pollAdapter;
private SimpleDateFormat shortSdf;
private SimpleDateFormat longSdf;
private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
protected int avatarRadius48dp;
private int avatarRadius36dp;
@ -127,7 +128,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
bookmarkButton = itemView.findViewById(R.id.status_bookmark);
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[]{
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()));
((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.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
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) {
avatarInset.setVisibility(View.VISIBLE);
avatarInset.setBackgroundColor(0x50ffffff);
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);
} else {
avatarInset.setVisibility(View.GONE);
}
@ -320,7 +318,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
protected void setCreatedAt(Date createdAt, StatusDisplayOptions statusDisplayOptions) {
if (statusDisplayOptions.useAbsoluteTime()) {
timestampInfo.setText(getAbsoluteTime(createdAt));
timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true));
} else {
if (createdAt == null) {
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,
StatusDisplayOptions statusDisplayOptions) {
if (statusDisplayOptions.useAbsoluteTime()) {
return getAbsoluteTime(createdAt);
return absoluteTimeFormatter.format(createdAt, true);
} else {
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
* as 17 meters instead of minutes. */
@ -736,9 +723,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
this.setupWithStatus(status, listener, statusDisplayOptions, null);
}
public void setupWithStatus(StatusViewData.Concrete status,
final StatusActionListener listener,
StatusDisplayOptions statusDisplayOptions,
public void setupWithStatus(@NonNull StatusViewData.Concrete status,
@NonNull final StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
if (payloads == null) {
Status actionable = status.getActionable();
@ -1028,7 +1015,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
return votesText;
} else {
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 {
pollDurationInfo = TimestampUtils.formatPollDuration(pollDescription.getContext(), poll.getExpiresAt().getTime(), timestamp);
}
@ -1043,9 +1030,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
StatusDisplayOptions statusDisplayOptions,
final StatusActionListener listener
) {
final Card card = status.getActionable().getCard();
final Status actionable = status.getActionable();
final Card card = actionable.getCard();
if (cardViewMode != CardViewMode.NONE &&
status.getActionable().getAttachments().size() == 0 &&
actionable.getAttachments().size() == 0 &&
actionable.getPoll() == null &&
card != null &&
!TextUtils.isEmpty(card.getUrl()) &&
(!status.isCollapsible() || !status.isCollapsed())) {
@ -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,
// so let's blur the preview in that case
// If media previews are disabled, show placeholder for cards as well
if (statusDisplayOptions.mediaPreviewEnabled() && !status.getActionable().getSensitive() && !TextUtils.isEmpty(card.getImage())) {
if (statusDisplayOptions.mediaPreviewEnabled() && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) {
int topLeftRadius = 0;
int topRightRadius = 0;
@ -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) {
int seconds = (int) Math.round(durationInSeconds) % 60;
int minutes = (int) durationInSeconds % 3600 / 60;

View file

@ -1,14 +1,12 @@
package com.keylesspalace.tusky.adapter;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.text.method.LinkMovementMethod;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
@ -101,9 +99,9 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
}
@Override
public void setupWithStatus(final StatusViewData.Concrete status,
final StatusActionListener listener,
StatusDisplayOptions statusDisplayOptions,
public void setupWithStatus(@NonNull final StatusViewData.Concrete status,
@NonNull final StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
super.setupWithStatus(status, listener, statusDisplayOptions, payloads);
setupCard(status, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
@ -118,19 +116,6 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
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());
}
}

View file

@ -22,6 +22,7 @@ import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
@ -58,9 +59,9 @@ public class StatusViewHolder extends StatusBaseViewHolder {
}
@Override
public void setupWithStatus(StatusViewData.Concrete status,
final StatusActionListener listener,
StatusDisplayOptions statusDisplayOptions,
public void setupWithStatus(@NonNull StatusViewData.Concrete status,
@NonNull final StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
if (payloads == null) {
@ -129,4 +130,9 @@ public class StatusViewHolder extends StatusBaseViewHolder {
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.res.ColorStateList
import android.graphics.Color
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.os.Bundle
import android.text.Editable
import android.view.Menu
@ -39,7 +37,6 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updatePadding
import androidx.emoji.text.EmojiCompat
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
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.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
@ -374,13 +371,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
.show()
}
}
viewModel.accountFieldData.observe(
this,
{
accountFieldAdapter.fields = it
accountFieldAdapter.notifyDataSetChanged()
}
)
viewModel.noteSaved.observe(this) {
binding.saveNoteInfo.visible(it, View.INVISIBLE)
}
@ -395,11 +385,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
adapter.refreshContent()
}
viewModel.isRefreshing.observe(
this,
{ isRefreshing ->
this
) { isRefreshing ->
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
}
)
binding.swipeToRefreshLayout.setColorSchemeResources(R.color.chinwag_green)
}
@ -410,10 +399,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountUsernameTextView.text = usernameFormatted
binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis)
val emojifiedNote = account.note.emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this)
// accountFieldAdapter.fields = account.fields ?: emptyList()
accountFieldAdapter.fields = account.fields ?: emptyList()
accountFieldAdapter.emojis = account.emojis ?: emptyList()
accountFieldAdapter.notifyDataSetChanged()
@ -469,14 +458,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
*/
private fun updateToolbar() {
loadedAccount?.let { account ->
val emojifiedName = account.name.emojify(account.emojis, binding.accountToolbar, animateEmojis)
try {
supportActionBar?.title = EmojiCompat.get().process(emojifiedName)
} catch (e: IllegalStateException) {
supportActionBar?.title = emojifiedName
}
supportActionBar?.title = account.name.emojify(account.emojis, binding.accountToolbar, animateEmojis)
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)
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
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
@ -23,12 +22,10 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAccountFieldBinding
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Field
import com.keylesspalace.tusky.entity.IdentityProof
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.createClickableText
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText
class AccountFieldAdapter(
@ -37,7 +34,7 @@ class AccountFieldAdapter(
) : RecyclerView.Adapter<BindingHolder<ItemAccountFieldBinding>>() {
var emojis: List<Emoji> = emptyList()
var fields: List<Either<IdentityProof, Field>> = emptyList()
var fields: List<Field> = emptyList()
override fun getItemCount() = fields.size
@ -47,25 +44,14 @@ class AccountFieldAdapter(
}
override fun onBindViewHolder(holder: BindingHolder<ItemAccountFieldBinding>, position: Int) {
val proofOrField = fields[position]
val field = fields[position]
val nameTextView = holder.binding.accountFieldName
val valueTextView = holder.binding.accountFieldValue
if (proofOrField.isLeft()) {
val identityProof = proofOrField.asLeft()
nameTextView.text = identityProof.provider
valueTextView.text = createClickableText(identityProof.username, identityProof.profileUrl)
valueTextView.movementMethod = LinkMovementMethod.getInstance()
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)
} else {
val field = proofOrField.asRight()
val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis)
nameTextView.text = emojifiedName
val emojifiedValue = field.value.emojify(emojis, valueTextView, animateEmojis)
val emojifiedValue = field.value.parseAsMastodonHtml().emojify(emojis, valueTextView, animateEmojis)
setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener)
if (field.verifiedAt != null) {
@ -75,4 +61,3 @@ class AccountFieldAdapter(
}
}
}
}

View file

@ -10,17 +10,13 @@ import com.keylesspalace.tusky.appstore.ProfileEditedEvent
import com.keylesspalace.tusky.appstore.UnfollowEvent
import com.keylesspalace.tusky.db.AccountManager
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.network.MastodonApi
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.combineOptionalLiveData
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable
import retrofit2.Call
@ -40,13 +36,6 @@ class AccountViewModel @Inject constructor(
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 }
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() {
val relationship = relationshipData.value?.data
if (relationship?.following == true || relationship?.requested == true) {
@ -314,7 +287,6 @@ class AccountViewModel @Inject constructor(
return
accountId.let {
obtainAccount(isReload)
obtainIdentityProof()
if (!isSelf)
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.EmojiSpan
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText
import java.lang.ref.WeakReference
@ -60,7 +61,7 @@ class AnnouncementAdapter(
val chips = holder.binding.chipGroup
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)

View file

@ -18,31 +18,26 @@ package com.keylesspalace.tusky.components.announcements
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.compose.DEFAULT_MAXIMUM_URL_LENGTH
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.InstanceEntity
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.entity.Announcement
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.Success
import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.launch
import javax.inject.Inject
class AnnouncementsViewModel @Inject constructor(
accountManager: AccountManager,
private val appDatabase: AppDatabase,
private val instanceInfoRepo: InstanceInfoRepository,
private val mastodonApi: MastodonApi,
private val eventHub: EventHub
) : RxAwareViewModel() {
) : ViewModel() {
private val announcementsMutable = MutableLiveData<Resource<List<Announcement>>>()
val announcements: LiveData<Resource<List<Announcement>>> = announcementsMutable
@ -51,72 +46,46 @@ class AnnouncementsViewModel @Inject constructor(
val emojis: LiveData<List<Emoji>> = emojisMutable
init {
Single.zip(
mastodonApi.getCustomEmojis(),
appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
.map<Either<InstanceEntity, Instance>> { Either.Left(it) }
.onErrorResumeNext {
mastodonApi.getInstance()
.map { Either.Right(it) }
viewModelScope.launch {
emojisMutable.postValue(instanceInfoRepo.getEmojis())
}
) { 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() {
viewModelScope.launch {
announcementsMutable.postValue(Loading())
mastodonApi.listAnnouncements()
.subscribe(
.fold(
{
announcementsMutable.postValue(Success(it))
it.filter { announcement -> !announcement.read }
.forEach { announcement ->
mastodonApi.dismissAnnouncement(announcement.id)
.subscribe(
.fold(
{
eventHub.dispatch(AnnouncementReadEvent(announcement.id))
},
{ throwable ->
Log.d(TAG, "Failed to mark announcement as read.", throwable)
Log.d(
TAG,
"Failed to mark announcement as read.",
throwable
)
}
)
.autoDispose()
}
},
{
announcementsMutable.postValue(Error(cause = it))
}
)
.autoDispose()
}
}
fun addReaction(announcementId: String, name: String) {
viewModelScope.launch {
mastodonApi.addAnnouncementReaction(announcementId, name)
.subscribe(
.fold(
{
announcementsMutable.postValue(
Success(
@ -161,12 +130,13 @@ class AnnouncementsViewModel @Inject constructor(
Log.w(TAG, "Failed to add reaction to the announcement.", it)
}
)
.autoDispose()
}
}
fun removeReaction(announcementId: String, name: String) {
viewModelScope.launch {
mastodonApi.removeAnnouncementReaction(announcementId, name)
.subscribe(
.fold(
{
announcementsMutable.postValue(
Success(
@ -199,7 +169,7 @@ class AnnouncementsViewModel @Inject constructor(
Log.w(TAG, "Failed to remove reaction from the announcement.", it)
}
)
.autoDispose()
}
}
companion object {

View file

@ -51,6 +51,8 @@ import androidx.core.view.ContentInfoCompat
import androidx.core.view.OnReceiveContentListener
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
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.view.ComposeOptionsListener
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.db.AccountEntity
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.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.io.File
import java.io.IOException
@ -123,8 +127,8 @@ class ComposeActivity :
private var photoUploadUri: Uri? = null
@VisibleForTesting
var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT
var charactersReservedPerUrl = DEFAULT_MAXIMUM_URL_LENGTH
var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT
var charactersReservedPerUrl = InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL
private val viewModel: ComposeViewModel by viewModels { viewModelFactory }
@ -328,11 +332,10 @@ class ComposeActivity :
private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) {
withLifecycleContext {
viewModel.instanceParams.observe { instanceData ->
viewModel.instanceInfo.observe { instanceData ->
maximumTootCharacters = instanceData.maxChars
charactersReservedPerUrl = instanceData.charactersReservedPerUrl
updateVisibleCharactersLeft()
binding.composeScheduleButton.visible(instanceData.supportsScheduled)
}
viewModel.emoji.observe { emoji -> setEmojiList(emoji) }
combineLiveData(viewModel.markMediaAsSensitive, viewModel.showContentWarning) { markSensitive, showContentWarning ->
@ -342,7 +345,8 @@ class ComposeActivity :
viewModel.statusVisibility.observe { visibility ->
setStatusVisibility(visibility)
}
viewModel.media.observe { media ->
lifecycleScope.launch {
viewModel.media.collect { media ->
mediaAdapter.submitList(media)
if (media.size != mediaCount) {
mediaCount = media.size
@ -350,6 +354,8 @@ class ComposeActivity :
updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value != false, viewModel.showContentWarning.value != false)
}
}
}
viewModel.poll.observe { poll ->
binding.pollPreview.visible(poll != null)
poll?.let(binding.pollPreview::setPoll)
@ -362,7 +368,7 @@ class ComposeActivity :
}
updateScheduleButton()
}
combineOptionalLiveData(viewModel.media, viewModel.poll) { media, poll ->
combineOptionalLiveData(viewModel.media.asLiveData(), viewModel.poll) { media, poll ->
val active = poll == null &&
media!!.size != 4 &&
(media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE)
@ -666,7 +672,7 @@ class ComposeActivity :
private fun openPollDialog() {
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
val instanceParams = viewModel.instanceParams.value!!
val instanceParams = viewModel.instanceInfo.value!!
showAddPollDialog(
this, viewModel.poll.value, instanceParams.pollMaxOptions,
instanceParams.pollMaxLength, instanceParams.pollMinDuration, instanceParams.pollMaxDuration,
@ -779,11 +785,11 @@ class ComposeActivity :
spoilerText = binding.composeContentWarningField.text.toString()
}
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)
enableButtons(true)
} else if (characterCount <= maximumTootCharacters) {
if (viewModel.media.value!!.isNotEmpty()) {
if (viewModel.media.value.isNotEmpty()) {
finishingUploadDialog = ProgressDialog.show(
this, getString(R.string.dialog_title_finishing_media_upload),
getString(R.string.dialog_message_uploading_media), true, true
@ -866,28 +872,18 @@ class ComposeActivity :
}
private fun pickMedia(uri: Uri) {
withLifecycleContext {
viewModel.pickMedia(uri).observe { exceptionOrItem ->
exceptionOrItem.asLeftOrNull()?.let {
val errorId = when (it) {
is VideoSizeException -> {
R.string.error_video_upload_size
}
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
}
lifecycleScope.launch {
viewModel.pickMedia(uri).onFailure { throwable ->
val errorId = when (throwable) {
is VideoSizeException -> R.string.error_video_upload_size
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)
}
}
}
}
private fun showContentWarning(show: Boolean) {
TransitionManager.beginDelayedTransition(binding.composeContentWarningBar.parent as ViewGroup)
@ -971,9 +967,20 @@ class ComposeActivity :
}
private fun saveDraftAndFinish(contentText: String, contentWarning: String) {
lifecycleScope.launch {
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> {
return viewModel.searchAutocompleteSuggestions(token)
@ -991,7 +998,7 @@ class ComposeActivity :
}
data class QueuedMedia(
val localId: Long,
val localId: Int,
val uri: Uri,
val type: Type,
val mediaSize: Long,

View file

@ -20,14 +20,15 @@ import android.util.Log
import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
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.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.Emoji
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.service.ServiceClient
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.filter
import com.keylesspalace.tusky.util.map
import com.keylesspalace.tusky.util.randomAlphanumericString
import com.keylesspalace.tusky.util.toLiveData
import com.keylesspalace.tusky.util.withoutFirstWhich
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable
import kotlinx.coroutines.Dispatchers
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.rx3.rxSingle
import kotlinx.coroutines.withContext
import java.util.Locale
import javax.inject.Inject
@ -57,8 +60,8 @@ class ComposeViewModel @Inject constructor(
private val mediaUploader: MediaUploader,
private val serviceClient: ServiceClient,
private val draftHelper: DraftHelper,
private val db: AppDatabase
) : RxAwareViewModel() {
private val instanceInfoRepo: InstanceInfoRepository
) : ViewModel() {
private var replyingStatusAuthor: String? = null
private var replyingStatusContent: String? = null
@ -72,19 +75,8 @@ class ComposeViewModel @Inject constructor(
private var contentWarningStateChanged: 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 markMediaAsSensitive =
mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
@ -95,131 +87,104 @@ class ComposeViewModel @Inject constructor(
val poll: MutableLiveData<NewPoll?> = 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>()
private val mediaToDisposable = mutableMapOf<Long, Disposable>()
private val mediaToJob = mutableMapOf<Int, Job>()
private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty()
init {
Single.zip(
api.getCustomEmojis(), api.getInstance()
) { emojis, instance ->
InstanceEntity(
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
)
viewModelScope.launch {
emoji.postValue(instanceInfoRepo.getEmojis())
}
.doOnSuccess {
db.instanceDao().insertOrReplace(it)
viewModelScope.launch {
instanceInfo.postValue(instanceInfoRepo.getInstanceInfo())
}
.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>> {
// We are not calling .toLiveData() here because we don't want to stop the process when
// the Activity goes away temporarily (like on screen rotation).
val liveData = MutableLiveData<Either<Throwable, QueuedMedia>>()
mediaUploader.prepareMedia(uri)
.map { (type, uri, size) ->
val mediaItems = media.value!!
suspend fun pickMedia(mediaUri: Uri, description: String? = null): Result<QueuedMedia> = withContext(Dispatchers.IO) {
try {
val (type, uri, size) = mediaUploader.prepareMedia(mediaUri)
val mediaItems = media.value
if (type != QueuedMedia.Type.IMAGE &&
mediaItems.isNotEmpty() &&
mediaItems[0].type == QueuedMedia.Type.IMAGE
) {
throw VideoOrImageException()
Result.failure(VideoOrImageException())
} else {
addMediaToQueue(type, uri, size, description)
val queuedMedia = addMediaToQueue(type, uri, size, description)
Result.success(queuedMedia)
}
} catch (e: Exception) {
Result.failure(e)
}
.subscribe(
{ queuedMedia ->
liveData.postValue(Either.Right(queuedMedia))
},
{ error ->
liveData.postValue(Either.Left(error))
}
)
.autoDispose()
return liveData
}
private fun addMediaToQueue(
private suspend fun addMediaToQueue(
type: QueuedMedia.Type,
uri: Uri,
mediaSize: Long,
description: String? = null
): QueuedMedia {
val mediaItem = media.updateAndGet { mediaValue ->
val mediaItem = QueuedMedia(
localId = System.currentTimeMillis(),
localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1,
uri = uri,
type = type,
mediaSize = mediaSize,
description = description
)
media.value = media.value!! + mediaItem
mediaToDisposable[mediaItem.localId] = mediaUploader
mediaValue + mediaItem
}.last()
mediaToJob[mediaItem.localId] = viewModelScope.launch {
mediaUploader
.uploadMedia(mediaItem)
.subscribe(
{ event ->
val item = media.value?.find { it.localId == mediaItem.localId }
?: 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) {
is UploadEvent.ProgressEvent ->
item.copy(uploadPercent = event.percentage)
is UploadEvent.FinishedEvent ->
item.copy(id = event.mediaId, uploadPercent = -1)
}
synchronized(media) {
val mediaValue = media.value!!
val index = mediaValue.indexOfFirst { it.localId == newMediaItem.localId }
media.postValue(
if (index == -1) {
mediaValue + newMediaItem
media.update { mediaValue ->
mediaValue.map { mediaItem ->
if (mediaItem.localId == newMediaItem.localId) {
newMediaItem
} else {
mediaValue.toMutableList().also { it[index] = newMediaItem }
mediaItem
}
}
}
)
}
},
{ error ->
media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList())
uploadError.postValue(error)
}
)
return mediaItem
}
private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?) {
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, 0, -1, id, description)
media.value = media.value!! + mediaItem
media.update { mediaValue ->
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) {
mediaToDisposable[item.localId]?.dispose()
media.value = media.value!!.withoutFirstWhich { it.localId == item.localId }
mediaToJob[item.localId]?.cancel()
media.update { mediaValue -> mediaValue.filter { it.localId != item.localId } }
}
fun toggleMarkSensitive() {
@ -255,11 +220,17 @@ class ComposeViewModel @Inject constructor(
}
}
fun saveDraft(content: String, contentWarning: String) {
viewModelScope.launch {
fun shouldShowSaveDraftDialog(): Boolean {
// if any of the media files need to be downloaded first it could take a while, so show a loading dialog
return media.value.any { mediaValue ->
mediaValue.uri.scheme == "https"
}
}
suspend fun saveDraft(content: String, contentWarning: String) {
val mediaUris: MutableList<String> = mutableListOf()
val mediaDescriptions: MutableList<String?> = mutableListOf()
media.value?.forEach { item ->
media.value.forEach { item ->
mediaUris.add(item.uri.toString())
mediaDescriptions.add(item.description)
}
@ -278,7 +249,6 @@ class ComposeViewModel @Inject constructor(
failedToSend = false
)
}
}
/**
* Send status to the server.
@ -291,21 +261,23 @@ class ComposeViewModel @Inject constructor(
): LiveData<Unit> {
val deletionObservable = if (isEditingScheduledToot) {
api.deleteScheduledStatus(scheduledTootId.toString()).toObservable().map { }
rxSingle { api.deleteScheduledStatus(scheduledTootId.toString()) }.toObservable().map { }
} else {
Observable.just(Unit)
}.toLiveData()
val sendObservable = media
val sendFlow = media
.filter { items -> items.all { it.uploadPercent == -1 } }
.map {
val mediaIds = ArrayList<String>()
val mediaUris = ArrayList<Uri>()
val mediaDescriptions = ArrayList<String>()
for (item in media.value!!) {
val mediaIds: MutableList<String> = mutableListOf()
val mediaUris: MutableList<Uri> = mutableListOf()
val mediaDescriptions: MutableList<String> = mutableListOf()
val mediaProcessed: MutableList<Boolean> = mutableListOf()
for (item in media.value) {
mediaIds.add(item.id!!)
mediaUris.add(item.uri)
mediaDescriptions.add(item.description ?: "")
mediaProcessed.add(false)
}
val tootToSend = StatusToSend(
@ -324,44 +296,38 @@ class ComposeViewModel @Inject constructor(
accountId = accountManager.activeAccount!!.id,
draftId = draftId,
idempotencyKey = randomAlphanumericString(16),
retries = 0
retries = 0,
mediaProcessed = mediaProcessed
)
serviceClient.sendToot(tootToSend)
}
return combineLiveData(deletionObservable, sendObservable) { _, _ -> }
return combineLiveData(deletionObservable, sendFlow.asLiveData()) { _, _ -> }
}
fun updateDescription(localId: Long, description: String): LiveData<Boolean> {
val newList = media.value!!.toMutableList()
val index = newList.indexOfFirst { it.localId == localId }
if (index != -1) {
newList[index] = newList[index].copy(description = description)
}
media.value = newList
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)
suspend fun updateDescription(localId: Int, description: String): Boolean {
val newMediaList = media.updateAndGet { mediaValue ->
mediaValue.map { mediaItem ->
if (mediaItem.localId == localId) {
mediaItem.copy(description = description)
} else {
mediaItem
}
}
}
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 completedCaptioningLiveData
}
return true
}
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
@ -443,7 +409,11 @@ class ComposeViewModel @Inject constructor(
val draftAttachments = composeOptions?.draftAttachments
if (draftAttachments != null) {
// 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 ->
// when coming from redraft or ScheduledTootActivity
val mediaType = when (a.type) {
@ -494,13 +464,6 @@ class ComposeViewModel @Inject constructor(
scheduledAt.value = newScheduledAt
}
override fun onCleared() {
for (uploadDisposable in mediaToDisposable.values) {
uploadDisposable.dispose()
}
super.onCleared()
}
private companion object {
const val TAG = "ComposeViewModel"
}
@ -508,25 +471,6 @@ class ComposeViewModel @Inject constructor(
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
*/

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.R
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.util.MEDIA_SIZE_UNKNOWN
import com.keylesspalace.tusky.util.getImageSquarePixels
import com.keylesspalace.tusky.util.getMediaSize
import com.keylesspalace.tusky.util.randomAlphanumericString
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.MultipartBody
import java.io.File
@ -70,24 +75,26 @@ class CouldNotOpenFileException : Exception()
class MediaUploader @Inject constructor(
private val context: Context,
private val mastodonApi: MastodonApi
private val mediaUploadApi: MediaUploadApi
) {
fun uploadMedia(media: QueuedMedia): Observable<UploadEvent> {
return Observable
.fromCallable {
@OptIn(ExperimentalCoroutinesApi::class)
fun uploadMedia(media: QueuedMedia): Flow<UploadEvent> {
return flow {
if (shouldResizeMedia(media)) {
downsize(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> {
return Single.fromCallable {
fun prepareMedia(inUri: Uri): PreparedMedia {
var mediaSize = MEDIA_SIZE_UNKNOWN
var uri = inUri
var mimeType: String? = null
val mimeType: String?
try {
when (inUri.scheme) {
@ -152,8 +159,7 @@ class MediaUploader @Inject constructor(
}
if (mimeType != null) {
val topLevelType = mimeType.substring(0, mimeType.indexOf('/'))
when (topLevelType) {
return when (mimeType.substring(0, mimeType.indexOf('/'))) {
"video" -> {
if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) {
throw VideoSizeException()
@ -178,12 +184,11 @@ class MediaUploader @Inject constructor(
throw MediaTypeException()
}
}
}
private val contentResolver = context.contentResolver
private fun upload(media: QueuedMedia): Observable<UploadEvent> {
return Observable.create { emitter ->
private suspend fun upload(media: QueuedMedia): Flow<UploadEvent> {
return callbackFlow {
var mimeType = contentResolver.getType(media.uri)
val map = MimeTypeMap.getSingleton()
val fileExtension = map.getExtensionFromMimeType(mimeType)
@ -200,11 +205,11 @@ class MediaUploader @Inject constructor(
var lastProgress = -1
val fileBody = ProgressRequestBody(
stream, media.mediaSize,
mimeType.toMediaTypeOrNull()
stream!!, media.mediaSize,
mimeType.toMediaTypeOrNull()!!
) { percentage ->
if (percentage != lastProgress) {
emitter.onNext(UploadEvent.ProgressEvent(percentage))
trySend(UploadEvent.ProgressEvent(percentage))
}
lastProgress = percentage
}
@ -217,28 +222,15 @@ class MediaUploader @Inject constructor(
null
}
val uploadDisposable = mastodonApi.uploadMedia(body, description)
.subscribe(
{ result ->
emitter.onNext(UploadEvent.FinishedEvent(result.id))
emitter.onComplete()
},
{ e ->
emitter.onError(e)
}
)
// Cancel the request when our observable is cancelled
emitter.setDisposable(uploadDisposable)
val result = mediaUploadApi.uploadMedia(body, description).getOrThrow()
send(UploadEvent.FinishedEvent(result.id))
awaitClose()
}
}
private fun downsize(media: QueuedMedia): QueuedMedia {
val file = createNewImageFile(context)
DownsizeImageTask.resize(
arrayOf(media.uri),
STATUS_IMAGE_SIZE_LIMIT, context.contentResolver, file
)
downsizeImage(media.uri, STATUS_IMAGE_SIZE_LIMIT, contentResolver, file)
return media.copy(uri = file.toUri(), mediaSize = file.length())
}

View file

@ -27,7 +27,7 @@ import android.widget.LinearLayout
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope
import at.connyduck.sparkbutton.helpers.Utils
import com.bumptech.glide.Glide
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.github.chrisbanes.photoview.PhotoView
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
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
@ -43,7 +43,7 @@ private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
fun <T> T.makeCaptionDialog(
existingDescription: String?,
previewUri: Uri,
onUpdateDescription: (String) -> LiveData<Boolean>
onUpdateDescription: suspend (String) -> Boolean
) where T : Activity, T : LifecycleOwner {
val dialogLayout = LinearLayout(this)
val padding = Utils.dpToPx(this, 8)
@ -77,12 +77,11 @@ fun <T> T.makeCaptionDialog(
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
val okListener = { dialog: DialogInterface, _: Int ->
onUpdateDescription(input.text.toString())
withLifecycleContext {
onUpdateDescription(input.text.toString())
.observe { success -> if (!success) showFailedCaptionMessage() }
lifecycleScope.launch {
if (!onUpdateDescription(input.text.toString())) {
showFailedCaptionMessage()
}
}
dialog.dismiss()
}

View file

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

View file

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

View file

@ -15,7 +15,6 @@
package com.keylesspalace.tusky.components.conversation
import android.text.Spanned
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.TypeConverters
@ -27,7 +26,7 @@ import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.shouldTrimStatus
import com.keylesspalace.tusky.viewdata.StatusViewData
import java.util.Date
@Entity(primaryKeys = ["id", "accountId"])
@ -38,7 +37,16 @@ data class ConversationEntity(
val accounts: List<ConversationAccountEntity>,
val unread: Boolean,
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
) {
fun toViewData(): ConversationViewData {
return ConversationViewData(
id = id,
accounts = accounts,
unread = unread,
lastStatus = lastStatus.toViewData()
)
}
}
data class ConversationAccountEntity(
val id: String,
@ -67,7 +75,7 @@ data class ConversationStatusEntity(
val inReplyToId: String?,
val inReplyToAccountId: String?,
val account: ConversationAccountEntity,
val content: Spanned,
val content: String,
val createdAt: Date,
val emojis: List<Emoji>,
val favouritesCount: Int,
@ -80,70 +88,14 @@ data class ConversationStatusEntity(
val tags: List<HashTag>?,
val showingHiddenContent: Boolean,
val expanded: Boolean,
val collapsible: Boolean,
val collapsed: Boolean,
val muted: Boolean,
val poll: Poll?
) {
/** its necessary to override this because Spanned.equals does not work as expected */
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ConversationStatusEntity
if (id != other.id) return false
if (url != other.url) return false
if (inReplyToId != other.inReplyToId) return false
if (inReplyToAccountId != other.inReplyToAccountId) return false
if (account != other.account) return false
if (content.toString() != other.content.toString()) return false
if (createdAt != other.createdAt) return false
if (emojis != other.emojis) return false
if (favouritesCount != other.favouritesCount) return false
if (favourited != other.favourited) return false
if (sensitive != other.sensitive) return false
if (spoilerText != other.spoilerText) return false
if (attachments != other.attachments) return false
if (mentions != other.mentions) return false
if (tags != other.tags) return false
if (showingHiddenContent != other.showingHiddenContent) return false
if (expanded != other.expanded) return false
if (collapsible != other.collapsible) return false
if (collapsed != other.collapsed) return false
if (muted != other.muted) return false
if (poll != other.poll) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + (url?.hashCode() ?: 0)
result = 31 * result + (inReplyToId?.hashCode() ?: 0)
result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0)
result = 31 * result + account.hashCode()
result = 31 * result + content.toString().hashCode()
result = 31 * result + createdAt.hashCode()
result = 31 * result + emojis.hashCode()
result = 31 * result + favouritesCount
result = 31 * result + favourited.hashCode()
result = 31 * result + sensitive.hashCode()
result = 31 * result + spoilerText.hashCode()
result = 31 * result + attachments.hashCode()
result = 31 * result + mentions.hashCode()
result = 31 * result + tags.hashCode()
result = 31 * result + showingHiddenContent.hashCode()
result = 31 * result + expanded.hashCode()
result = 31 * result + collapsible.hashCode()
result = 31 * result + collapsed.hashCode()
result = 31 * result + muted.hashCode()
result = 31 * result + poll.hashCode()
return result
}
fun toStatus(): Status {
return Status(
fun toViewData(): StatusViewData.Concrete {
return StatusViewData.Concrete(
status = Status(
id = id,
url = url,
account = account.toAccount(),
@ -169,6 +121,10 @@ data class ConversationStatusEntity(
muted = muted,
poll = poll,
card = null
),
isExpanded = expanded,
isShowingContent = showingHiddenContent,
isCollapsed = collapsed
)
}
}
@ -202,7 +158,6 @@ fun Status.toEntity() =
tags = tags,
showingHiddenContent = false,
expanded = false,
collapsible = shouldTrimStatus(content),
collapsed = true,
muted = muted ?: false,
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.adapter.StatusBaseViewHolder;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.List;
@ -69,11 +72,12 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height);
}
void setupWithConversation(ConversationEntity conversation) {
ConversationStatusEntity status = conversation.getLastStatus();
ConversationAccountEntity account = status.getAccount();
void setupWithConversation(ConversationViewData conversation) {
StatusViewData.Concrete statusViewData = conversation.getLastStatus();
Status status = statusViewData.getStatus();
TimelineAccount account = status.getAccount();
setupCollapsedState(status.getCollapsible(), status.getCollapsed(), status.getExpanded(), status.getSpoilerText(), listener);
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
setUsername(account.getUsername());
@ -84,7 +88,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
List<Attachment> attachments = status.getAttachments();
boolean sensitive = status.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent(),
setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
statusDisplayOptions.useBlurhash());
if (attachments.size() == 0) {
@ -95,7 +99,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
mediaLabel.setVisibility(View.GONE);
}
} else {
setMediaLabel(attachments, sensitive, listener, status.getShowingHiddenContent());
setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
// Hide all unused views.
mediaPreviews[0].setVisibility(View.GONE);
mediaPreviews[1].setVisibility(View.GONE);
@ -104,10 +108,10 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
hideSensitiveMediaWarning();
}
setupButtons(listener, account.getId(), status.getContent().toString(),
setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
statusDisplayOptions);
setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(),
setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
status.getMentions(), status.getTags(), status.getEmojis(),
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);

View file

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

View file

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

View file

@ -30,7 +30,12 @@ import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.IOUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.buffer
import okio.sink
import java.io.File
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@ -38,6 +43,7 @@ import javax.inject.Inject
class DraftHelper @Inject constructor(
val context: Context,
val okHttpClient: OkHttpClient,
db: AppDatabase
) {
@ -71,11 +77,11 @@ class DraftHelper @Inject constructor(
val uris = mediaUris.map { uriString ->
uriString.toUri()
}.map { uri ->
if (uri.isNotInFolder(draftDirectory)) {
uri.copyToFolder(draftDirectory)
} else {
}.mapNotNull { uri ->
if (uri.isInFolder(draftDirectory)) {
uri
} else {
uri.copyToFolder(draftDirectory)
}
}
@ -114,6 +120,7 @@ class DraftHelper @Inject constructor(
)
draftDao.insertOrReplace(draft)
Log.d("DraftHelper", "saved draft to db")
}
suspend fun deleteDraftAndAttachments(draftId: Int) {
@ -133,33 +140,55 @@ class DraftHelper @Inject constructor(
}
}
suspend fun deleteAttachments(draft: DraftEntity) {
withContext(Dispatchers.IO) {
suspend fun deleteAttachments(draft: DraftEntity) = withContext(Dispatchers.IO) {
draft.attachments.forEach { attachment ->
if (context.contentResolver.delete(attachment.uri, null, null) == 0) {
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
return File(filePath).parentFile == folder
}
private fun Uri.copyToFolder(folder: File): Uri {
private fun Uri.copyToFolder(folder: File): Uri? {
val contentResolver = context.contentResolver
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val fileExtension = if (scheme == "https") {
lastPathSegment?.substringAfterLast('.', "tmp")
} else {
val mimeType = contentResolver.getType(this)
val map = MimeTypeMap.getSingleton()
val fileExtension = map.getExtensionFromMimeType(mimeType)
map.getExtensionFromMimeType(mimeType)
}
val filename = String.format("Tusky_Draft_Media_%s.%s", timeStamp, fileExtension)
val file = File(folder, filename)
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)
}
}

View file

@ -35,6 +35,7 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.databinding.ActivityDraftsBinding
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.visible
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.flow.collectLatest
@ -100,7 +101,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
content = draft.content,
contentWarning = draft.contentWarning,
inReplyToId = draft.inReplyToId,
replyingStatusContent = status.content.toString(),
replyingStatusContent = status.content.parseAsMastodonHtml().toString(),
replyingStatusAuthor = status.account.localUsername,
draftAttachments = draft.attachments,
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.databinding.ActivityLoginBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.AppCredentials
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.rickRoll
@ -70,7 +69,9 @@ class LoginActivity : BaseActivity(), Injectable {
// Authorization failed. Put the error response where the user can read it and they
// can try again.
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(
TAG,
"%s %s".format(
@ -180,20 +181,11 @@ class LoginActivity : BaseActivity(), Injectable {
setLoading(true)
lifecycleScope.launch {
val credentials: AppCredentials = try {
mastodonApi.authenticateApp(
domain, getString(R.string.app_name), oauthRedirectUri,
OAUTH_SCOPES, getString(R.string.tusky_website)
)
} catch (e: Exception) {
binding.loginButton.isEnabled = true
binding.domainTextInputLayout.error =
getString(R.string.error_failed_app_registration)
setLoading(false)
Log.e(TAG, Log.getStackTraceString(e))
return@launch
}
).fold(
{ credentials ->
// Before we open browser page we save the data.
// Even if we don't open other apps user may go to password manager or somewhere else
// and we will need to pick up the process where we left off.
@ -206,6 +198,16 @@ class LoginActivity : BaseActivity(), Injectable {
.apply()
redirectUserToAuthorizeAndLogin(domain, credentials.clientId)
},
{ e ->
binding.loginButton.isEnabled = true
binding.domainTextInputLayout.error =
getString(R.string.error_failed_app_registration)
setLoading(false)
Log.e(TAG, Log.getStackTraceString(e))
return@launch
}
)
}
}
@ -238,22 +240,10 @@ class LoginActivity : BaseActivity(), Injectable {
setLoading(true)
val accessToken = try {
mastodonApi.fetchOAuthToken(
domain, clientId, clientSecret, oauthRedirectUri, code,
"authorization_code"
)
} catch (e: Exception) {
setLoading(false)
binding.domainTextInputLayout.error =
getString(R.string.error_retrieving_oauth_token)
Log.e(
TAG,
"%s %s".format(getString(R.string.error_retrieving_oauth_token), e.message),
)
return
}
domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code"
).fold(
{ accessToken ->
accountManager.addAccount(accessToken.accessToken, domain)
val intent = Intent(this, MainActivity::class.java)
@ -261,6 +251,17 @@ class LoginActivity : BaseActivity(), Injectable {
startActivity(intent)
finish()
overridePendingTransition(R.anim.explode, R.anim.explode)
},
{ e ->
setLoading(false)
binding.domainTextInputLayout.error =
getString(R.string.error_retrieving_oauth_token)
Log.e(
TAG,
"%s %s".format(getString(R.string.error_retrieving_oauth_token), e.message),
)
}
)
}
private fun setLoading(loadingState: Boolean) {

View file

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

View file

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

View file

@ -38,14 +38,11 @@ import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizePx
import okhttp3.OkHttpClient
import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference
import javax.inject.Inject
class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
@Inject
lateinit var okhttpclient: OkHttpClient
@Inject
lateinit var accountManager: AccountManager
@ -65,11 +62,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
icon = makeIcon(GoogleMaterial.Icon.gmd_palette)
}
emojiPreference(okhttpclient) {
setDefaultValue("system_default")
setIcon(R.drawable.ic_emoji_24dp)
key = PrefKeys.EMOJI
setSummary(R.string.system_default)
emojiPreference(requireActivity()) {
setTitle(R.string.emoji_style)
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 {
fun newInstance(): PreferencesFragment {
return PreferencesFragment()

View file

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

View file

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

View file

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

View file

@ -84,6 +84,10 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector {
return true
}
override fun finish() {
super.finishWithoutSlideOutAnimation()
}
private fun getPageTitle(position: Int): CharSequence {
return when (position) {
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) {
bottomSheetActivity?.viewUrl(url)

View file

@ -97,7 +97,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
}
override fun onReply(position: Int) {
searchAdapter.peek(position)?.status?.let { status ->
searchAdapter.peek(position)?.let { status ->
reply(status)
}
}
@ -199,8 +199,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
fun newInstance() = SearchStatusesFragment()
}
private fun reply(status: Status) {
val actionableStatus = status.actionableStatus
private fun reply(status: StatusViewData.Concrete) {
val actionableStatus = status.actionable
val mentionedUsernames = actionableStatus.mentions.map { it.username }
.toMutableSet()
.apply {
@ -216,10 +216,10 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
contentWarning = actionableStatus.spoilerText,
mentionedUsernames = mentionedUsernames,
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) {

View file

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

View file

@ -15,22 +15,18 @@
package com.keylesspalace.tusky.components.timeline
import android.text.SpannedString
import androidx.core.text.parseAsHtml
import androidx.core.text.toHtml
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.keylesspalace.tusky.db.TimelineAccountEntity
import com.keylesspalace.tusky.db.TimelineStatusEntity
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Card
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.shouldTrimStatus
import com.keylesspalace.tusky.util.trimTrailingWhitespace
import com.keylesspalace.tusky.viewdata.StatusViewData
import java.util.Date
@ -101,7 +97,8 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
expanded = loading,
contentCollapsed = false,
contentShowing = false,
pinned = false
pinned = false,
card = null,
)
}
@ -119,7 +116,7 @@ fun Status.toEntity(
authorServerId = actionableStatus.account.id,
inReplyToId = actionableStatus.inReplyToId,
inReplyToAccountId = actionableStatus.inReplyToAccountId,
content = actionableStatus.content.toHtml(),
content = actionableStatus.content,
createdAt = actionableStatus.createdAt.time,
emojis = actionableStatus.emojis.let(gson::toJson),
reblogsCount = actionableStatus.reblogsCount,
@ -141,7 +138,8 @@ fun Status.toEntity(
expanded = expanded,
contentShowing = contentShowing,
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 emojis: List<Emoji> = gson.fromJson(status.emojis, emojisListType) ?: emptyList()
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 ->
Status(
@ -165,8 +164,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
reblog = null,
content = status.content?.parseAsHtml()?.trimTrailingWhitespace()
?: SpannedString(""),
content = status.content.orEmpty(),
createdAt = Date(status.createdAt),
emojis = emojis,
reblogsCount = status.reblogsCount,
@ -184,7 +182,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
pinned = false,
muted = status.muted,
poll = poll,
card = null
card = card,
)
}
val status = if (reblog != null) {
@ -195,7 +193,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
inReplyToId = null,
inReplyToAccountId = null,
reblog = reblog,
content = SpannedString(""),
content = "",
createdAt = Date(status.createdAt), // lie but whatever?
emojis = listOf(),
reblogsCount = 0,
@ -223,8 +221,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
reblog = null,
content = status.content?.parseAsHtml()?.trimTrailingWhitespace()
?: SpannedString(""),
content = status.content.orEmpty(),
createdAt = Date(status.createdAt),
emojis = emojis,
reblogsCount = status.reblogsCount,
@ -242,14 +239,13 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
pinned = status.pinned,
muted = status.muted,
poll = poll,
card = null
card = card,
)
}
return StatusViewData.Concrete(
status = status,
isExpanded = this.status.expanded,
isShowingContent = this.status.contentShowing,
isCollapsible = shouldTrimStatus(status.content),
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.TimelineCases
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
@ -79,15 +82,13 @@ class CachedTimelineViewModel @Inject constructor(
}
).flow
.map { pagingData ->
pagingData.map { timelineStatus ->
pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus ->
timelineStatus.toViewData(gson)
}
}
.map { pagingData ->
pagingData.filter { statusViewData ->
}.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
!shouldFilterStatus(statusViewData)
}
}
.flowOn(Dispatchers.Default)
.cachedIn(viewModelScope)
init {

View file

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

View file

@ -50,6 +50,8 @@ data class AccountEntity(
var notificationsFavorited: Boolean = true,
var notificationsPolls: Boolean = true,
var notificationsSubscriptions: Boolean = true,
var notificationsSignUps: Boolean = true,
var notificationsUpdates: Boolean = true,
var notificationSound: Boolean = true,
var notificationVibration: 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,
TimelineAccountEntity.class, ConversationEntity.class
}, version = 31)
}, version = 35)
public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao();
@ -483,4 +483,62 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("DELETE FROM `TimelineStatusEntity`");
}
};
public static final Migration MIGRATION_31_32 = new Migration(31, 32) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsSignUps` INTEGER NOT NULL DEFAULT 1");
}
};
public static final Migration MIGRATION_32_33 = new Migration(32, 33) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
// ConversationEntity lost the s_collapsible column
// since SQLite does not support removing columns and it is just a cache table, we recreate the whole table.
database.execSQL("DROP TABLE `ConversationEntity`");
database.execSQL("CREATE TABLE IF NOT EXISTS `ConversationEntity` (" +
"`accountId` INTEGER NOT NULL," +
"`id` TEXT NOT NULL," +
"`accounts` TEXT NOT NULL," +
"`unread` INTEGER NOT NULL," +
"`s_id` TEXT NOT NULL," +
"`s_url` TEXT," +
"`s_inReplyToId` TEXT," +
"`s_inReplyToAccountId` TEXT," +
"`s_account` TEXT NOT NULL," +
"`s_content` TEXT NOT NULL," +
"`s_createdAt` INTEGER NOT NULL," +
"`s_emojis` TEXT NOT NULL," +
"`s_favouritesCount` INTEGER NOT NULL," +
"`s_favourited` INTEGER NOT NULL," +
"`s_bookmarked` INTEGER NOT NULL," +
"`s_sensitive` INTEGER NOT NULL," +
"`s_spoilerText` TEXT NOT NULL," +
"`s_attachments` TEXT NOT NULL," +
"`s_mentions` TEXT NOT NULL," +
"`s_tags` TEXT," +
"`s_showingHiddenContent` INTEGER NOT NULL," +
"`s_expanded` INTEGER NOT NULL," +
"`s_collapsed` INTEGER NOT NULL," +
"`s_muted` INTEGER NOT NULL," +
"`s_poll` TEXT," +
"PRIMARY KEY(`id`, `accountId`))");
}
};
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.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@ -31,8 +30,8 @@ interface ConversationsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(conversation: ConversationEntity): Long
@Delete
suspend fun delete(conversation: ConversationEntity): Int
@Query("DELETE FROM ConversationEntity WHERE id = :id AND accountId = :accountId")
suspend fun delete(id: String, accountId: Long): Int
@Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC")
fun conversationsForAccount(accountId: Long): PagingSource<Int, ConversationEntity>

View file

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

View file

@ -19,13 +19,19 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import io.reactivex.rxjava3.core.Single
@Dao
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")
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
@TypeConverters(Converters::class)
data class InstanceEntity(
@field:PrimaryKey var instance: String,
@PrimaryKey val instance: String,
val emojiList: List<Emoji>?,
val maximumTootCharacters: Int?,
val maxPollOptions: Int?,
@ -33,3 +33,20 @@ data class InstanceEntity(
val charactersReservedPerUrl: Int?,
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.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.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.localUsername as 'a_localUsername', a.username as 'a_username',
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 contentCollapsed: Boolean,
val contentShowing: Boolean,
val pinned: Boolean
val pinned: Boolean,
val card: String?,
)
@Entity(

View file

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

View file

@ -18,14 +18,13 @@ package com.keylesspalace.tusky.di
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.text.Spanned
import at.connyduck.calladapter.kotlinresult.KotlinResultCallAdapterFactory
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.json.SpannedTypeAdapter
import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.MediaUploadApi
import com.keylesspalace.tusky.util.getNonNullString
import dagger.Module
import dagger.Provides
@ -51,11 +50,7 @@ class NetworkModule {
@Provides
@Singleton
fun providesGson(): Gson {
return GsonBuilder()
.registerTypeAdapter(Spanned::class.java, SpannedTypeAdapter())
.create()
}
fun providesGson() = Gson()
@Provides
@Singleton
@ -111,10 +106,25 @@ class NetworkModule {
.client(httpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
.addCallAdapterFactory(KotlinResultCallAdapterFactory.create())
.build()
}
@Provides
@Singleton
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
import android.text.Spanned
import com.google.gson.annotations.SerializedName
import java.util.Date
@ -24,7 +23,7 @@ data class Account(
@SerializedName("username") val localUsername: String,
@SerializedName("acct") val username: String,
@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 avatar: String,
val header: String,
@ -46,56 +45,6 @@ data class Account(
} else displayName
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(
@ -107,7 +56,7 @@ data class AccountSource(
data class Field(
val name: String,
val value: Spanned,
val value: String,
@SerializedName("verified_at") val verifiedAt: Date?
)

View file

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

View file

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

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

View file

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

View file

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

View file

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

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.Emoji
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.IdentityProof
import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.entity.Marker
import com.keylesspalace.tusky.entity.MastoList
@ -77,10 +76,10 @@ interface MastodonApi {
fun getLists(): Single<List<MastoList>>
@GET("/api/v1/custom_emojis")
fun getCustomEmojis(): Single<List<Emoji>>
suspend fun getCustomEmojis(): Result<List<Emoji>>
@GET("api/v1/instance")
fun getInstance(): Single<Instance>
suspend fun getInstance(): Result<Instance>
@GET("api/v1/filters")
fun getFilters(): Single<List<Filter>>
@ -143,27 +142,25 @@ interface MastodonApi {
@POST("api/v1/notifications/clear")
fun clearNotifications(): Single<ResponseBody>
@Multipart
@POST("api/v2/media")
fun uploadMedia(
@Part file: MultipartBody.Part,
@Part description: MultipartBody.Part? = null
): Single<MediaUploadResult>
@FormUrlEncoded
@PUT("api/v1/media/{mediaId}")
fun updateMedia(
suspend fun updateMedia(
@Path("mediaId") mediaId: 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")
fun createStatus(
suspend fun createStatus(
@Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String,
@Header("Idempotency-Key") idempotencyKey: String,
@Body status: NewStatus
): Call<Status>
): Result<Status>
@GET("api/v1/statuses/{id}")
fun status(
@ -249,12 +246,12 @@ interface MastodonApi {
): Single<List<ScheduledStatus>>
@DELETE("api/v1/scheduled_statuses/{id}")
fun deleteScheduledStatus(
suspend fun deleteScheduledStatus(
@Path("id") scheduledStatusId: String
): Single<ResponseBody>
): Result<ResponseBody>
@GET("api/v1/accounts/verify_credentials")
fun accountVerifyCredentials(): Single<Account>
suspend fun accountVerifyCredentials(): Result<Account>
@FormUrlEncoded
@PATCH("api/v1/accounts/update_credentials")
@ -265,7 +262,7 @@ interface MastodonApi {
@Multipart
@PATCH("api/v1/accounts/update_credentials")
fun accountUpdateCredentials(
suspend fun accountUpdateCredentials(
@Part(value = "display_name") displayName: RequestBody?,
@Part(value = "note") note: RequestBody?,
@Part(value = "locked") locked: RequestBody?,
@ -279,7 +276,7 @@ interface MastodonApi {
@Part(value = "fields_attributes[2][value]") fieldValue2: RequestBody?,
@Part(value = "fields_attributes[3][name]") fieldName3: RequestBody?,
@Part(value = "fields_attributes[3][value]") fieldValue3: RequestBody?
): Call<Account>
): Result<Account>
@GET("api/v1/accounts/search")
fun searchAccounts(
@ -367,11 +364,6 @@ interface MastodonApi {
@Query("id[]") accountIds: List<String>
): 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")
fun subscribeAccount(
@Path("id") accountId: String
@ -447,7 +439,7 @@ interface MastodonApi {
@Field("redirect_uris") redirectUris: String,
@Field("scopes") scopes: String,
@Field("website") website: String
): AppCredentials
): Result<AppCredentials>
@FormUrlEncoded
@POST("oauth/token")
@ -458,7 +450,7 @@ interface MastodonApi {
@Field("redirect_uri") redirectUri: String,
@Field("code") code: String,
@Field("grant_type") grantType: String
): AccessToken
): Result<AccessToken>
@FormUrlEncoded
@POST("api/v1/lists")
@ -544,26 +536,26 @@ interface MastodonApi {
): Single<Poll>
@GET("api/v1/announcements")
fun listAnnouncements(
suspend fun listAnnouncements(
@Query("with_dismissed") withDismissed: Boolean = true
): Single<List<Announcement>>
): Result<List<Announcement>>
@POST("api/v1/announcements/{id}/dismiss")
fun dismissAnnouncement(
suspend fun dismissAnnouncement(
@Path("id") announcementId: String
): Single<ResponseBody>
): Result<ResponseBody>
@PUT("api/v1/announcements/{id}/reactions/{name}")
fun addAnnouncementReaction(
suspend fun addAnnouncementReaction(
@Path("id") announcementId: String,
@Path("name") name: String
): Single<ResponseBody>
): Result<ResponseBody>
@DELETE("api/v1/announcements/{id}/reactions/{name}")
fun removeAnnouncementReaction(
suspend fun removeAnnouncementReaction(
@Path("id") announcementId: String,
@Path("name") name: String
): Single<ResponseBody>
): Result<ResponseBody>
@FormUrlEncoded
@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,
draftId = -1,
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.IBinder
import android.os.Parcelable
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
@ -29,13 +30,12 @@ import com.keylesspalace.tusky.network.MastodonApi
import dagger.android.AndroidInjection
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import retrofit2.HttpException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@ -55,7 +55,7 @@ class SendStatusService : Service(), Injectable {
private val serviceScope = CoroutineScope(Dispatchers.Main + supervisorJob)
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 }
@ -64,12 +64,9 @@ class SendStatusService : Service(), Injectable {
super.onCreate()
}
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onBind(intent: Intent): IBinder? = null
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
if (intent.hasExtra(KEY_STATUS)) {
val statusToSend = intent.getParcelableExtra<StatusToSend>(KEY_STATUS)
?: throw IllegalStateException("SendStatusService started without $KEY_STATUS extra")
@ -129,6 +126,25 @@ class SendStatusService : Service(), Injectable {
statusToSend.retries++
sendJobs[statusId] = serviceScope.launch {
try {
var mediaCheckRetries = 0
while (statusToSend.mediaProcessed.any { !it }) {
delay(1000L * mediaCheckRetries)
statusToSend.mediaProcessed.forEachIndexed { index, processed ->
if (!processed) {
// Mastodon returns 206 if the media was not yet processed
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 newStatus = NewStatus(
statusToSend.text,
statusToSend.warningText,
@ -140,37 +156,32 @@ class SendStatusService : Service(), Injectable {
statusToSend.poll
)
val sendCall = mastodonApi.createStatus(
mastodonApi.createStatus(
"Bearer " + account.accessToken,
account.domain,
statusToSend.idempotencyKey,
newStatus
)
sendCalls[statusId] = sendCall
val callback = object : Callback<Status> {
override fun onResponse(call: Call<Status>, response: Response<Status>) {
serviceScope.launch {
val scheduled = !statusToSend.scheduledAt.isNullOrEmpty()
).fold({ sentStatus ->
statusesToSend.remove(statusId)
if (response.isSuccessful) {
// If the status was loaded from a draft, delete the draft and associated media files.
if (statusToSend.draftId != 0) {
draftHelper.deleteDraftAndAttachments(statusToSend.draftId)
}
val scheduled = !statusToSend.scheduledAt.isNullOrEmpty()
if (scheduled) {
response.body()?.let(::StatusScheduledEvent)?.let(eventHub::dispatch)
eventHub.dispatch(StatusScheduledEvent(sentStatus))
} else {
response.body()?.let(::StatusComposedEvent)?.let(eventHub::dispatch)
eventHub.dispatch(StatusComposedEvent(sentStatus))
}
notificationManager.cancel(statusId)
} else {
}, { 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)
saveStatusToDrafts(statusToSend)
val builder = NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID)
@ -186,26 +197,24 @@ class SendStatusService : Service(), Injectable {
notificationManager.cancel(statusId)
notificationManager.notify(errorNotificationId--, builder.build())
} else {
// 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
}
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)
}
}
}
sendCall.enqueue(callback)
}
private fun stopSelfWhenDone() {
@ -218,8 +227,8 @@ class SendStatusService : Service(), Injectable {
private fun cancelSending(statusId: Int) = serviceScope.launch {
val statusToCancel = statusesToSend.remove(statusId)
if (statusToCancel != null) {
val sendCall = sendCalls.remove(statusId)
sendCall?.cancel()
val sendJob = sendJobs.remove(statusId)
sendJob?.cancel()
saveStatusToDrafts(statusToCancel)
@ -263,6 +272,7 @@ class SendStatusService : Service(), Injectable {
}
companion object {
private const val TAG = "SendStatusService"
private const val KEY_STATUS = "status"
private const val KEY_CANCEL = "cancel_id"
@ -319,5 +329,6 @@ data class StatusToSend(
val accountId: Long,
val draftId: Int,
val idempotencyKey: String,
var retries: Int
var retries: Int,
val mediaProcessed: MutableList<Boolean>
) : Parcelable

View file

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

View file

@ -1,7 +1,9 @@
package com.keylesspalace.tusky.settings
import android.content.Context
import androidx.activity.result.ActivityResultRegistryOwner
import androidx.annotation.StringRes
import androidx.lifecycle.LifecycleOwner
import androidx.preference.CheckBoxPreference
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
@ -10,8 +12,7 @@ import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreference
import com.keylesspalace.tusky.components.preference.EmojiPreference
import okhttp3.OkHttpClient
import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference
class PreferenceParent(
val context: Context,
@ -32,8 +33,9 @@ inline fun PreferenceParent.listPreference(builder: ListPreference.() -> Unit):
return pref
}
inline fun PreferenceParent.emojiPreference(okHttpClient: OkHttpClient, builder: EmojiPreference.() -> Unit): EmojiPreference {
val pref = EmojiPreference(context, okHttpClient)
inline fun <A> PreferenceParent.emojiPreference(activity: A, builder: EmojiPickerPreference.() -> Unit): EmojiPickerPreference
where A : Context, A : ActivityResultRegistryOwner, A : LifecycleOwner {
val pref = EmojiPickerPreference.get(activity)
builder(pref)
addPref(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.calculatePercent
import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.min
class StatusViewHelper(private val itemView: View) {
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
interface MediaPreviewListener {
fun onViewMedia(v: View?, idx: Int)
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(
statusDisplayOptions: StatusDisplayOptions,
attachments: List<Attachment>,
@ -295,7 +291,7 @@ class StatusViewHelper(private val itemView: View) {
context.getString(R.string.poll_info_closed)
} else {
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 {
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 {
val COLLAPSE_INPUT_FILTER = arrayOf<InputFilter>(SmartLengthInputFilter)
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,
isCollapsed: Boolean
): StatusViewData.Concrete {
val visibleStatus = this.reblog ?: this
return StatusViewData.Concrete(
status = this,
isShowingContent = isShowingContent,
isCollapsible = shouldTrimStatus(visibleStatus.content),
isCollapsed = isCollapsed,
isExpanded = isExpanded,
)

View file

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

View file

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

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:viewportWidth="24">
<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" />
</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_constraintTop_toTopOf="@+id/accountFollowButton" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/accountDisplayNameTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -215,7 +215,7 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountNoteTextInputLayout" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/accountNoteTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -248,36 +248,42 @@
app:layout_constraintTop_toBottomOf="@id/accountFieldList"
tools:visibility="visible" />
<androidx.constraintlayout.widget.Group
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/accountMovedView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
app:constraint_referenced_ids="accountMovedText,accountMovedAvatar,accountMovedDisplayName,accountMovedUsername" />
android:layout_marginTop="4dp"
app:layout_constraintTop_toBottomOf="@id/accountRemoveView"
tools:visibility="visible">
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/accountMovedText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginTop="8dp"
android:drawablePadding="6dp"
android:drawableStart="@drawable/ic_briefcase"
android:textSize="?attr/status_text_medium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountRemoveView"
app:layout_constraintTop_toTopOf="parent"
tools:text="Account has moved" />
<ImageView
android:importantForAccessibility="no"
android:id="@+id/accountMovedAvatar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_centerVertical="true"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:layout_marginEnd="24dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountMovedText"
tools:src="@drawable/avatar_default" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/accountMovedDisplayName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -306,6 +312,8 @@
app:layout_constraintTop_toBottomOf="@id/accountMovedDisplayName"
tools:text="\@username" />
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
android:id="@+id/accountStatuses"
android:layout_width="wrap_content"
@ -315,7 +323,7 @@
android:orientation="vertical"
app:layout_constraintEnd_toStartOf="@id/accountFollowing"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountMovedAvatar">
app:layout_constraintTop_toBottomOf="@id/accountMovedView">
<TextView
android:id="@+id/accountStatusesTextView"
@ -346,7 +354,7 @@
android:orientation="vertical"
app:layout_constraintEnd_toStartOf="@id/accountFollowers"
app:layout_constraintStart_toEndOf="@id/accountStatuses"
app:layout_constraintTop_toBottomOf="@id/accountMovedAvatar">
app:layout_constraintTop_toBottomOf="@id/accountMovedView">
<TextView
android:id="@+id/accountFollowingTextView"
@ -376,7 +384,7 @@
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/accountFollowing"
app:layout_constraintTop_toBottomOf="@id/accountMovedAvatar">
app:layout_constraintTop_toBottomOf="@id/accountMovedView">
<TextView
android:id="@+id/accountFollowersTextView"

View file

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

View file

@ -11,11 +11,16 @@
<androidx.appcompat.widget.Toolbar
android:id="@+id/loginToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:titleTextColor="#fff" />
android:layout_height="wrap_content" />
</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
android:id="@+id/loginWebView"
android:layout_width="match_parent"

View file

@ -58,6 +58,7 @@
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:tabIndicator="@null"
app:tabGravity="fill"
app:tabMode="fixed" />
</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:visibility="visible" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/account_display_name"
android:layout_width="0dp"
android:layout_height="wrap_content"

View file

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

View file

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

View file

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

View file

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

View file

@ -11,7 +11,7 @@
android:paddingStart="12dp"
android:paddingEnd="14dp">
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/conversation_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -79,7 +79,7 @@
tools:src="#000"
tools:visibility="visible" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/status_display_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -122,7 +122,7 @@
app:layout_constraintTop_toTopOf="@id/status_display_name"
tools:text="13:37" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/status_content_warning_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
@ -157,7 +157,7 @@
tools:text="@string/post_content_warning_show_more"
tools:visibility="visible" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/status_content"
android:layout_width="0dp"
android:layout_height="wrap_content"

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