ComposeActivity refactor (#1541)
* Convert ComposeActivity to Kotlin * More ComposeActivity cleanups * Move ComposeActivity to it's own package * Remove ComposeActivity.IntentBuilder * Re-do part of the media downsizing/uploading * Add sending of status to ViewModel, draft media descriptions * Allow uploading video, update description after uploading * Enable camera, enable upload cancelling * Cleanup of ComposeActivity * Extract CaptionDialog, extract ComposeActivity methods * Fix handling of redrafted media * Add initial state and media uploading out of Activity * Change ComposeOptions.mentionedUsernames to be Set rather than List We probably don't want repeated usernames when we are writing a post and Set provides such guarantee for free plus it tells it to the callers. The only disadvantage is lack of order but it shouldn't be a problem. * Add combineOptionalLiveData. Add docs. It it useful for nullable LiveData's. I think we cannot differentiate between value not being set and value being null so I just added the variant without null check. * Add poll support to Compose. * cleanup code * move more classes into compose package * cleanup code * fix button behavior * add error handling for media upload * add caching for instance data again * merge develop * fix scheduled toots * delete unused string * cleanup ComposeActivity * fix restoring media from drafts * make media upload code a little bit clearer * cleanup autocomplete search code * avoid duplicate object creation in SavedTootActivity * perf: avoid unnecessary work when initializing ComposeActivity * add license header to new files * use small toot button on bigger displays * fix ComposeActivityTest * fix bad merge * use Singles.zip instead of Single.zip
This commit is contained in:
parent
a882e6c067
commit
9c59d000f9
@ -92,6 +92,7 @@ project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
|
||||
}
|
||||
}
|
||||
|
||||
ext.lifecycleVersion = "2.1.0"
|
||||
ext.roomVersion = '2.2.1'
|
||||
ext.retrofitVersion = '2.6.0'
|
||||
ext.okhttpVersion = '4.2.2'
|
||||
@ -114,7 +115,8 @@ dependencies {
|
||||
implementation "androidx.sharetarget:sharetarget:1.0.0-beta01"
|
||||
implementation "androidx.emoji:emoji:1.0.0"
|
||||
implementation "androidx.emoji:emoji-appcompat:1.0.0"
|
||||
implementation "androidx.lifecycle:lifecycle-extensions:2.1.0"
|
||||
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycleVersion"
|
||||
implementation "androidx.lifecycle:lifecycle-reactivestreams:$lifecycleVersion"
|
||||
implementation "androidx.constraintlayout:constraintlayout:1.1.3"
|
||||
implementation "androidx.paging:paging-runtime-ktx:2.1.0"
|
||||
implementation "androidx.viewpager2:viewpager2:1.0.0-rc01"
|
||||
|
729
app/schemas/com.keylesspalace.tusky.db.AppDatabase/21.json
Normal file
729
app/schemas/com.keylesspalace.tusky.db.AppDatabase/21.json
Normal file
@ -0,0 +1,729 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 21,
|
||||
"identityHash": "7570c84ffeb4f90521f91dc7ef3e7da1",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "TootEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "text",
|
||||
"columnName": "text",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "urls",
|
||||
"columnName": "urls",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "descriptions",
|
||||
"columnName": "descriptions",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentWarning",
|
||||
"columnName": "contentWarning",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "inReplyToId",
|
||||
"columnName": "inReplyToId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "inReplyToText",
|
||||
"columnName": "inReplyToText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "inReplyToUsername",
|
||||
"columnName": "inReplyToUsername",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "visibility",
|
||||
"columnName": "visibility",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "poll",
|
||||
"columnName": "poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "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, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` 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": "notificationsReblogged",
|
||||
"columnName": "notificationsReblogged",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsFavorited",
|
||||
"columnName": "notificationsFavorited",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsPolls",
|
||||
"columnName": "notificationsPolls",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationSound",
|
||||
"columnName": "notificationSound",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationVibration",
|
||||
"columnName": "notificationVibration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationLight",
|
||||
"columnName": "notificationLight",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "defaultPostPrivacy",
|
||||
"columnName": "defaultPostPrivacy",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "defaultMediaSensitivity",
|
||||
"columnName": "defaultMediaSensitivity",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "alwaysShowSensitiveMedia",
|
||||
"columnName": "alwaysShowSensitiveMedia",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "alwaysOpenSpoiler",
|
||||
"columnName": "alwaysOpenSpoiler",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mediaPreviewEnabled",
|
||||
"columnName": "mediaPreviewEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastNotificationId",
|
||||
"columnName": "lastNotificationId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "activeNotifications",
|
||||
"columnName": "activeNotifications",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "emojis",
|
||||
"columnName": "emojis",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "tabPreferences",
|
||||
"columnName": "tabPreferences",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsFilter",
|
||||
"columnName": "notificationsFilter",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_AccountEntity_domain_accountId",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"domain",
|
||||
"accountId"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "InstanceEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "instance",
|
||||
"columnName": "instance",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "emojiList",
|
||||
"columnName": "emojiList",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maximumTootCharacters",
|
||||
"columnName": "maximumTootCharacters",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollOptions",
|
||||
"columnName": "maxPollOptions",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollOptionLength",
|
||||
"columnName": "maxPollOptionLength",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "version",
|
||||
"columnName": "version",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"instance"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "TimelineStatusEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "serverId",
|
||||
"columnName": "serverId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "timelineUserId",
|
||||
"columnName": "timelineUserId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authorServerId",
|
||||
"columnName": "authorServerId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "inReplyToId",
|
||||
"columnName": "inReplyToId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "inReplyToAccountId",
|
||||
"columnName": "inReplyToAccountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "content",
|
||||
"columnName": "content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "emojis",
|
||||
"columnName": "emojis",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "reblogsCount",
|
||||
"columnName": "reblogsCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "favouritesCount",
|
||||
"columnName": "favouritesCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "reblogged",
|
||||
"columnName": "reblogged",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookmarked",
|
||||
"columnName": "bookmarked",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "favourited",
|
||||
"columnName": "favourited",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sensitive",
|
||||
"columnName": "sensitive",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "spoilerText",
|
||||
"columnName": "spoilerText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "visibility",
|
||||
"columnName": "visibility",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mentions",
|
||||
"columnName": "mentions",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "application",
|
||||
"columnName": "application",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "reblogServerId",
|
||||
"columnName": "reblogServerId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "reblogAccountId",
|
||||
"columnName": "reblogAccountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "poll",
|
||||
"columnName": "poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"serverId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"authorServerId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "TimelineAccountEntity",
|
||||
"onDelete": "NO ACTION",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"authorServerId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"serverId",
|
||||
"timelineUserId"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "TimelineAccountEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "serverId",
|
||||
"columnName": "serverId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timelineUserId",
|
||||
"columnName": "timelineUserId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "localUsername",
|
||||
"columnName": "localUsername",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "avatar",
|
||||
"columnName": "avatar",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "emojis",
|
||||
"columnName": "emojis",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bot",
|
||||
"columnName": "bot",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"serverId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "ConversationEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accounts",
|
||||
"columnName": "accounts",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "unread",
|
||||
"columnName": "unread",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.id",
|
||||
"columnName": "s_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.url",
|
||||
"columnName": "s_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.inReplyToId",
|
||||
"columnName": "s_inReplyToId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.inReplyToAccountId",
|
||||
"columnName": "s_inReplyToAccountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.account",
|
||||
"columnName": "s_account",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.content",
|
||||
"columnName": "s_content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.createdAt",
|
||||
"columnName": "s_createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.emojis",
|
||||
"columnName": "s_emojis",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.favouritesCount",
|
||||
"columnName": "s_favouritesCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.favourited",
|
||||
"columnName": "s_favourited",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.bookmarked",
|
||||
"columnName": "s_bookmarked",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.sensitive",
|
||||
"columnName": "s_sensitive",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.spoilerText",
|
||||
"columnName": "s_spoilerText",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.attachments",
|
||||
"columnName": "s_attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.mentions",
|
||||
"columnName": "s_mentions",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.showingHiddenContent",
|
||||
"columnName": "s_showingHiddenContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.expanded",
|
||||
"columnName": "s_expanded",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.collapsible",
|
||||
"columnName": "s_collapsible",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.collapsed",
|
||||
"columnName": "s_collapsed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.poll",
|
||||
"columnName": "s_poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"accountId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7570c84ffeb4f90521f91dc7ef3e7da1')"
|
||||
]
|
||||
}
|
||||
}
|
@ -96,7 +96,7 @@
|
||||
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ComposeActivity"
|
||||
android:name=".components.compose.ComposeActivity"
|
||||
android:theme="@style/TuskyDialogActivityTheme"
|
||||
android:windowSoftInputMode="stateVisible|adjustResize"/>
|
||||
<activity
|
||||
|
@ -48,6 +48,7 @@ import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.keylesspalace.tusky.adapter.AccountFieldAdapter
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.report.ReportActivity
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
@ -693,9 +694,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
||||
|
||||
private fun mention() {
|
||||
loadedAccount?.let {
|
||||
val intent = ComposeActivity.IntentBuilder()
|
||||
.mentionedUsernames(setOf(it.username))
|
||||
.build(this)
|
||||
val intent = ComposeActivity.startIntent(this,
|
||||
ComposeActivity.ComposeOptions(mentionedUsernames = setOf(it.username)))
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -44,6 +44,7 @@ import com.keylesspalace.tusky.appstore.CacheUpdater;
|
||||
import com.keylesspalace.tusky.appstore.EventHub;
|
||||
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent;
|
||||
import com.keylesspalace.tusky.appstore.ProfileEditedEvent;
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity;
|
||||
import com.keylesspalace.tusky.components.conversation.ConversationsRepository;
|
||||
import com.keylesspalace.tusky.components.search.SearchActivity;
|
||||
import com.keylesspalace.tusky.db.AccountEntity;
|
||||
|
@ -22,21 +22,6 @@ import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.keylesspalace.tusky.adapter.SavedTootAdapter;
|
||||
import com.keylesspalace.tusky.appstore.EventHub;
|
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent;
|
||||
import com.keylesspalace.tusky.db.AppDatabase;
|
||||
import com.keylesspalace.tusky.db.TootDao;
|
||||
import com.keylesspalace.tusky.db.TootEntity;
|
||||
import com.keylesspalace.tusky.di.Injectable;
|
||||
import com.keylesspalace.tusky.util.SaveTootHelper;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
@ -44,16 +29,35 @@ import androidx.lifecycle.Lifecycle;
|
||||
import androidx.recyclerview.widget.DividerItemDecoration;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.keylesspalace.tusky.adapter.SavedTootAdapter;
|
||||
import com.keylesspalace.tusky.appstore.EventHub;
|
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent;
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity;
|
||||
import com.keylesspalace.tusky.db.AppDatabase;
|
||||
import com.keylesspalace.tusky.db.TootDao;
|
||||
import com.keylesspalace.tusky.db.TootEntity;
|
||||
import com.keylesspalace.tusky.di.Injectable;
|
||||
import com.keylesspalace.tusky.util.SaveTootHelper;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
|
||||
import static com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions;
|
||||
import static com.uber.autodispose.AutoDispose.autoDisposable;
|
||||
import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from;
|
||||
|
||||
public final class SavedTootActivity extends BaseActivity implements SavedTootAdapter.SavedTootAction,
|
||||
Injectable {
|
||||
|
||||
private SaveTootHelper saveTootHelper;
|
||||
|
||||
// ui
|
||||
private SavedTootAdapter adapter;
|
||||
private TextView noContent;
|
||||
@ -66,13 +70,13 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd
|
||||
EventHub eventHub;
|
||||
@Inject
|
||||
AppDatabase database;
|
||||
@Inject
|
||||
SaveTootHelper saveTootHelper;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
saveTootHelper = new SaveTootHelper(database.tootDao(), this);
|
||||
|
||||
eventHub.getEvents()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.ofType(StatusComposedEvent.class)
|
||||
@ -153,18 +157,29 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd
|
||||
|
||||
@Override
|
||||
public void click(int position, TootEntity item) {
|
||||
Intent intent = new ComposeActivity.IntentBuilder()
|
||||
.savedTootUid(item.getUid())
|
||||
.tootText(item.getText())
|
||||
.contentWarning(item.getContentWarning())
|
||||
.savedJsonUrls(item.getUrls())
|
||||
.savedJsonDescriptions(item.getDescriptions())
|
||||
.inReplyToId(item.getInReplyToId())
|
||||
.replyingStatusAuthor(item.getInReplyToUsername())
|
||||
.replyingStatusContent(item.getInReplyToText())
|
||||
.visibility(item.getVisibility())
|
||||
.poll(item.getPoll())
|
||||
.build(this);
|
||||
Gson gson = new Gson();
|
||||
Type stringListType = new TypeToken<List<String>>() {}.getType();
|
||||
List<String> jsonUrls = gson.fromJson(item.getUrls(), stringListType);
|
||||
List<String> descriptions = gson.fromJson(item.getDescriptions(), stringListType);
|
||||
|
||||
ComposeOptions composeOptions = new ComposeOptions(
|
||||
item.getUid(),
|
||||
item.getText(),
|
||||
jsonUrls,
|
||||
descriptions,
|
||||
/*mentionedUsernames*/null,
|
||||
item.getInReplyToId(),
|
||||
/*replyVisibility*/null,
|
||||
item.getVisibility(),
|
||||
item.getContentWarning(),
|
||||
item.getInReplyToUsername(),
|
||||
item.getInReplyToText(),
|
||||
/*mediaAttachments*/null,
|
||||
/*scheduledAt*/null,
|
||||
/*sensitive*/null,
|
||||
/*poll*/null
|
||||
);
|
||||
Intent intent = ComposeActivity.startIntent(this, composeOptions);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
|
@ -11,6 +11,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.keylesspalace.tusky.adapter.ScheduledTootAdapter
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.StatusScheduledEvent
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.entity.ScheduledStatus
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
@ -135,15 +136,15 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootAdapter.ScheduledToot
|
||||
if (item == null) {
|
||||
return
|
||||
}
|
||||
val intent = ComposeActivity.IntentBuilder()
|
||||
.tootText(item.params.text)
|
||||
.contentWarning(item.params.spoilerText)
|
||||
.mediaAttachments(item.mediaAttachments)
|
||||
.inReplyToId(item.params.inReplyToId)
|
||||
.visibility(item.params.visibility)
|
||||
.scheduledAt(item.scheduledAt)
|
||||
.sensitive(item.params.sensitive)
|
||||
.build(this)
|
||||
val intent = ComposeActivity.startIntent(this, ComposeActivity.ComposeOptions(
|
||||
tootText = item.params.text,
|
||||
contentWarning = item.params.spoilerText,
|
||||
mediaAttachments = item.mediaAttachments,
|
||||
inReplyToId = item.params.inReplyToId,
|
||||
visibility = item.params.visibility,
|
||||
scheduledAt = item.scheduledAt,
|
||||
sensitive = item.params.sensitive
|
||||
))
|
||||
startActivity(intent)
|
||||
delete(position, item)
|
||||
}
|
||||
|
@ -72,7 +72,7 @@ public class TuskyApplication extends Application implements HasAndroidInjector
|
||||
AppDatabase.MIGRATION_11_12, AppDatabase.MIGRATION_12_13, AppDatabase.MIGRATION_10_13,
|
||||
AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15, AppDatabase.MIGRATION_15_16,
|
||||
AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19,
|
||||
AppDatabase.MIGRATION_19_20)
|
||||
AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21)
|
||||
.build();
|
||||
accountManager = new AccountManager(appDatabase);
|
||||
serviceLocator = new ServiceLocator() {
|
||||
|
@ -0,0 +1,994 @@
|
||||
/* Copyright 2019 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.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.ProgressDialog
|
||||
import android.app.TimePickerDialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import androidx.preference.PreferenceManager
|
||||
import android.provider.MediaStore
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat
|
||||
import androidx.core.view.inputmethod.InputContentInfoCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.transition.TransitionManager
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.BuildConfig
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter
|
||||
import com.keylesspalace.tusky.adapter.EmojiAdapter
|
||||
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
|
||||
import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog
|
||||
import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.NewPoll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.util.*
|
||||
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
|
||||
import com.mikepenz.google_material_typeface_library.GoogleMaterial
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
import kotlinx.android.synthetic.main.activity_compose.*
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
class ComposeActivity : BaseActivity(),
|
||||
ComposeOptionsListener,
|
||||
ComposeAutoCompleteAdapter.AutocompletionProvider,
|
||||
OnEmojiSelectedListener,
|
||||
Injectable,
|
||||
InputConnectionCompat.OnCommitContentListener,
|
||||
TimePickerDialog.OnTimeSetListener {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private lateinit var composeOptionsBehavior: BottomSheetBehavior<*>
|
||||
private lateinit var addMediaBehavior: BottomSheetBehavior<*>
|
||||
private lateinit var emojiBehavior: BottomSheetBehavior<*>
|
||||
private lateinit var scheduleBehavior: BottomSheetBehavior<*>
|
||||
|
||||
// this only exists when a status is trying to be sent, but uploads are still occurring
|
||||
private var finishingUploadDialog: ProgressDialog? = null
|
||||
private var currentInputContentInfo: InputContentInfoCompat? = null
|
||||
private var currentFlags: Int = 0
|
||||
private var photoUploadUri: Uri? = null
|
||||
@VisibleForTesting
|
||||
var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT
|
||||
|
||||
private var composeOptions: ComposeOptions? = null
|
||||
private lateinit var viewModel: ComposeViewModel
|
||||
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT)
|
||||
if (theme == "black") {
|
||||
setTheme(R.style.TuskyDialogActivityBlackTheme)
|
||||
}
|
||||
setContentView(R.layout.activity_compose)
|
||||
|
||||
setupActionBar()
|
||||
// do not do anything when not logged in, activity will be finished in super.onCreate() anyway
|
||||
val activeAccount = accountManager.activeAccount ?: return
|
||||
|
||||
setupAvatar(preferences, activeAccount)
|
||||
val mediaAdapter = MediaPreviewAdapter(
|
||||
this,
|
||||
onAddCaption = { item ->
|
||||
makeCaptionDialog(item.description, item.uri) { newDescription ->
|
||||
viewModel.updateDescription(item.localId, newDescription)
|
||||
}
|
||||
},
|
||||
onRemove = this::removeMediaFromQueue
|
||||
)
|
||||
composeMediaPreviewBar.layoutManager =
|
||||
LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
|
||||
composeMediaPreviewBar.adapter = mediaAdapter
|
||||
composeMediaPreviewBar.itemAnimator = null
|
||||
|
||||
viewModel = ViewModelProviders.of(this, viewModelFactory)[ComposeViewModel::class.java]
|
||||
|
||||
subscribeToUpdates(mediaAdapter)
|
||||
setupButtons()
|
||||
|
||||
/* If the composer is started up as a reply to another post, override the "starting" state
|
||||
* based on what the intent from the reply request passes. */
|
||||
if (intent != null) {
|
||||
this.composeOptions = intent.getParcelableExtra<ComposeOptions?>(COMPOSE_OPTIONS_EXTRA)
|
||||
viewModel.setup(composeOptions)
|
||||
setupReplyViews(composeOptions?.replyingStatusAuthor)
|
||||
val tootText = composeOptions?.tootText
|
||||
if (!tootText.isNullOrEmpty()) {
|
||||
composeEditField.setText(tootText)
|
||||
}
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(composeOptions?.scheduledAt)) {
|
||||
composeScheduleView.setDateTime(composeOptions?.scheduledAt)
|
||||
}
|
||||
|
||||
setupComposeField(viewModel.startingText)
|
||||
setupContentWarningField(composeOptions?.contentWarning)
|
||||
setupPollView()
|
||||
applyShareIntent(intent, savedInstanceState)
|
||||
|
||||
composeEditField.requestFocus()
|
||||
}
|
||||
|
||||
private fun applyShareIntent(intent: Intent?, savedInstanceState: Bundle?) {
|
||||
if (intent != null && savedInstanceState == null) {
|
||||
/* Get incoming images being sent through a share action from another app. Only do this
|
||||
* when savedInstanceState is null, otherwise both the images from the intent and the
|
||||
* instance state will be re-queued. */
|
||||
val type = intent.type
|
||||
if (type != null) {
|
||||
if (type.startsWith("image/") || type.startsWith("video/")) {
|
||||
val uriList = ArrayList<Uri>()
|
||||
if (intent.action != null) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_SEND -> {
|
||||
val uri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
if (uri != null) {
|
||||
uriList.add(uri)
|
||||
}
|
||||
}
|
||||
Intent.ACTION_SEND_MULTIPLE -> {
|
||||
val list = intent.getParcelableArrayListExtra<Uri>(
|
||||
Intent.EXTRA_STREAM)
|
||||
if (list != null) {
|
||||
for (uri in list) {
|
||||
if (uri != null) {
|
||||
uriList.add(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (uri in uriList) {
|
||||
pickMedia(uri)
|
||||
}
|
||||
} else if (type == "text/plain") {
|
||||
val action = intent.action
|
||||
if (action != null && action == Intent.ACTION_SEND) {
|
||||
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
val shareBody = if (subject != null && text != null) {
|
||||
if (subject != text && !text.contains(subject)) {
|
||||
String.format("%s\n%s", subject, text)
|
||||
} else {
|
||||
text
|
||||
}
|
||||
} else text ?: subject
|
||||
|
||||
if (shareBody != null) {
|
||||
val start = composeEditField.selectionStart.coerceAtLeast(0)
|
||||
val end = composeEditField.selectionEnd.coerceAtLeast(0)
|
||||
val left = min(start, end)
|
||||
val right = max(start, end)
|
||||
composeEditField.text.replace(left, right, shareBody, 0, shareBody.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupReplyViews(replyingStatusAuthor: String?) {
|
||||
if (replyingStatusAuthor != null) {
|
||||
composeReplyView.show()
|
||||
composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor)
|
||||
val arrowDownIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_down).sizeDp(12)
|
||||
|
||||
ThemeUtils.setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary)
|
||||
composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null)
|
||||
|
||||
composeReplyView.setOnClickListener {
|
||||
TransitionManager.beginDelayedTransition(composeReplyContentView.parent as ViewGroup)
|
||||
|
||||
if (composeReplyContentView.isVisible) {
|
||||
composeReplyContentView.hide()
|
||||
composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null)
|
||||
} else {
|
||||
composeReplyContentView.show()
|
||||
val arrowUpIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_up).sizeDp(12)
|
||||
|
||||
ThemeUtils.setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary)
|
||||
composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowUpIcon, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
composeOptions?.replyingStatusContent?.let { composeReplyContentView.text = it }
|
||||
}
|
||||
|
||||
private fun setupContentWarningField(startingContentWarning: String?) {
|
||||
if (startingContentWarning != null) {
|
||||
composeContentWarningField.setText(startingContentWarning)
|
||||
}
|
||||
composeContentWarningField.onTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() }
|
||||
}
|
||||
|
||||
private fun setupComposeField(startingText: String?) {
|
||||
composeEditField.setOnCommitContentListener(this)
|
||||
|
||||
composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) }
|
||||
|
||||
composeEditField.setAdapter(
|
||||
ComposeAutoCompleteAdapter(this))
|
||||
composeEditField.setTokenizer(ComposeTokenizer())
|
||||
|
||||
composeEditField.setText(startingText)
|
||||
composeEditField.setSelection(composeEditField.length())
|
||||
|
||||
val mentionColour = composeEditField.linkTextColors.defaultColor
|
||||
highlightSpans(composeEditField.text, mentionColour)
|
||||
composeEditField.afterTextChanged { editable ->
|
||||
highlightSpans(editable, mentionColour)
|
||||
updateVisibleCharactersLeft()
|
||||
}
|
||||
|
||||
// work around Android platform bug -> https://issuetracker.google.com/issues/67102093
|
||||
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O
|
||||
|| Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1) {
|
||||
composeEditField.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) {
|
||||
withLifecycleContext {
|
||||
viewModel.instanceParams.observe { instanceData ->
|
||||
maximumTootCharacters = instanceData.maxChars
|
||||
updateVisibleCharactersLeft()
|
||||
composeScheduleButton.visible(instanceData.supportsScheduled)
|
||||
}
|
||||
viewModel.emoji.observe { emoji -> setEmojiList(emoji) }
|
||||
combineLiveData(viewModel.markMediaAsSensitive, viewModel.showContentWarning) { markSensitive, showContentWarning ->
|
||||
updateSensitiveMediaToggle(markSensitive, showContentWarning)
|
||||
showContentWarning(showContentWarning)
|
||||
}.subscribe()
|
||||
viewModel.statusVisibility.observe { visibility ->
|
||||
setStatusVisibility(visibility)
|
||||
}
|
||||
viewModel.media.observe { media ->
|
||||
composeMediaPreviewBar.visible(media.isNotEmpty())
|
||||
mediaAdapter.submitList(media)
|
||||
updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value != false, viewModel.showContentWarning.value != false)
|
||||
}
|
||||
viewModel.poll.observe { poll ->
|
||||
pollPreview.visible(poll != null)
|
||||
poll?.let(pollPreview::setPoll)
|
||||
}
|
||||
viewModel.scheduledAt.observe {scheduledAt ->
|
||||
if(scheduledAt == null) {
|
||||
composeScheduleView.resetSchedule()
|
||||
} else {
|
||||
composeScheduleView.setDateTime(scheduledAt)
|
||||
}
|
||||
updateScheduleButton()
|
||||
}
|
||||
combineOptionalLiveData(viewModel.media, viewModel.poll) { media, poll ->
|
||||
val active = poll == null
|
||||
&& media!!.size != 4
|
||||
&& media.firstOrNull()?.type != QueuedMedia.Type.VIDEO
|
||||
enableButton(composeAddMediaButton, active, active)
|
||||
enablePollButton(media.isNullOrEmpty())
|
||||
}.subscribe()
|
||||
viewModel.uploadError.observe {
|
||||
displayTransientError(R.string.error_media_upload_sending)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupButtons() {
|
||||
composeOptionsBottomSheet.listener = this
|
||||
|
||||
composeOptionsBehavior = BottomSheetBehavior.from(composeOptionsBottomSheet)
|
||||
addMediaBehavior = BottomSheetBehavior.from(addMediaBottomSheet)
|
||||
scheduleBehavior = BottomSheetBehavior.from(composeScheduleView)
|
||||
emojiBehavior = BottomSheetBehavior.from(emojiView)
|
||||
|
||||
emojiView.layoutManager = GridLayoutManager(this, 3, GridLayoutManager.HORIZONTAL, false)
|
||||
enableButton(composeEmojiButton, clickable = false, colorActive = false)
|
||||
|
||||
// Setup the interface buttons.
|
||||
composeTootButton.setOnClickListener { onSendClicked() }
|
||||
composeAddMediaButton.setOnClickListener { openPickDialog() }
|
||||
composeToggleVisibilityButton.setOnClickListener { showComposeOptions() }
|
||||
composeContentWarningButton.setOnClickListener { onContentWarningChanged() }
|
||||
composeEmojiButton.setOnClickListener { showEmojis() }
|
||||
composeHideMediaButton.setOnClickListener { toggleHideMedia() }
|
||||
composeScheduleButton.setOnClickListener { onScheduleClick() }
|
||||
composeScheduleView.setResetOnClickListener { resetSchedule() }
|
||||
atButton.setOnClickListener { atButtonClicked() }
|
||||
hashButton.setOnClickListener { hashButtonClicked() }
|
||||
|
||||
val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
|
||||
val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).color(textColor).sizeDp(18)
|
||||
actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null)
|
||||
|
||||
val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).color(textColor).sizeDp(18)
|
||||
actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(imageIcon, null, null, null)
|
||||
|
||||
val pollIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).color(textColor).sizeDp(18)
|
||||
addPollTextActionTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(pollIcon, null, null, null)
|
||||
|
||||
actionPhotoTake.setOnClickListener { initiateCameraApp() }
|
||||
actionPhotoPick.setOnClickListener { onMediaPick() }
|
||||
addPollTextActionTextView.setOnClickListener { openPollDialog() }
|
||||
}
|
||||
|
||||
private fun setupActionBar() {
|
||||
setSupportActionBar(toolbar)
|
||||
supportActionBar?.run {
|
||||
title = null
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setDisplayShowHomeEnabled(true)
|
||||
val closeIcon = AppCompatResources.getDrawable(this@ComposeActivity, R.drawable.ic_close_24dp)
|
||||
ThemeUtils.setDrawableTint(this@ComposeActivity, closeIcon!!, R.attr.compose_close_button_tint)
|
||||
setHomeAsUpIndicator(closeIcon)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun setupAvatar(preferences: SharedPreferences, activeAccount: AccountEntity) {
|
||||
val actionBarSizeAttr = intArrayOf(R.attr.actionBarSize)
|
||||
val a = obtainStyledAttributes(null, actionBarSizeAttr)
|
||||
val avatarSize = a.getDimensionPixelSize(0, 1)
|
||||
a.recycle()
|
||||
|
||||
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
||||
loadAvatar(
|
||||
activeAccount.profilePictureUrl,
|
||||
composeAvatar,
|
||||
avatarSize / 8,
|
||||
animateAvatars
|
||||
)
|
||||
composeAvatar.contentDescription = getString(R.string.compose_active_account_description,
|
||||
activeAccount.fullName)
|
||||
}
|
||||
|
||||
private fun replaceTextAtCaret(text: CharSequence) {
|
||||
// If you select "backward" in an editable, you get SelectionStart > SelectionEnd
|
||||
val start = composeEditField.selectionStart.coerceAtMost(composeEditField.selectionEnd)
|
||||
val end = composeEditField.selectionStart.coerceAtLeast(composeEditField.selectionEnd)
|
||||
composeEditField.text.replace(start, end, text)
|
||||
|
||||
// Set the cursor after the inserted text
|
||||
composeEditField.setSelection(start + text.length)
|
||||
}
|
||||
|
||||
private fun atButtonClicked() {
|
||||
replaceTextAtCaret("@")
|
||||
}
|
||||
|
||||
private fun hashButtonClicked() {
|
||||
replaceTextAtCaret("#")
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
if (currentInputContentInfo != null) {
|
||||
outState.putParcelable("commitContentInputContentInfo",
|
||||
currentInputContentInfo!!.unwrap() as Parcelable?)
|
||||
outState.putInt("commitContentFlags", currentFlags)
|
||||
}
|
||||
currentInputContentInfo = null
|
||||
currentFlags = 0
|
||||
outState.putParcelable("photoUploadUri", photoUploadUri)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
private fun displayTransientError(@StringRes stringId: Int) {
|
||||
val bar = Snackbar.make(activityCompose, stringId, Snackbar.LENGTH_LONG)
|
||||
//necessary so snackbar is shown over everything
|
||||
bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
|
||||
bar.show()
|
||||
}
|
||||
|
||||
private fun toggleHideMedia() {
|
||||
this.viewModel.toggleMarkSensitive()
|
||||
}
|
||||
|
||||
private fun updateSensitiveMediaToggle(markMediaSensitive: Boolean, contentWarningShown: Boolean) {
|
||||
TransitionManager.beginDelayedTransition(composeHideMediaButton.parent as ViewGroup)
|
||||
|
||||
if (viewModel.media.value.isNullOrEmpty()) {
|
||||
composeHideMediaButton.hide()
|
||||
} else {
|
||||
composeHideMediaButton.show()
|
||||
@ColorInt val color = if (contentWarningShown) {
|
||||
composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp)
|
||||
composeHideMediaButton.isClickable = false
|
||||
ContextCompat.getColor(this, R.color.compose_media_visible_button_disabled_blue)
|
||||
|
||||
} else {
|
||||
composeHideMediaButton.isClickable = true
|
||||
if (markMediaSensitive) {
|
||||
composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp)
|
||||
ContextCompat.getColor(this, R.color.tusky_blue)
|
||||
} else {
|
||||
composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp)
|
||||
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
}
|
||||
}
|
||||
composeHideMediaButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateScheduleButton() {
|
||||
@ColorInt val color = if (composeScheduleView.time == null) {
|
||||
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
} else {
|
||||
ContextCompat.getColor(this, R.color.tusky_blue)
|
||||
}
|
||||
composeScheduleButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
|
||||
private fun enableButtons(enable: Boolean) {
|
||||
composeAddMediaButton.isClickable = enable
|
||||
composeToggleVisibilityButton.isClickable = enable
|
||||
composeEmojiButton.isClickable = enable
|
||||
composeHideMediaButton.isClickable = enable
|
||||
composeScheduleButton.isClickable = enable
|
||||
composeTootButton.isEnabled = enable
|
||||
}
|
||||
|
||||
private fun setStatusVisibility(visibility: Status.Visibility) {
|
||||
composeOptionsBottomSheet.setStatusVisibility(visibility)
|
||||
composeTootButton.setStatusVisibility(visibility)
|
||||
|
||||
val iconRes = when (visibility) {
|
||||
Status.Visibility.PUBLIC -> R.drawable.ic_public_24dp
|
||||
Status.Visibility.PRIVATE -> R.drawable.ic_lock_outline_24dp
|
||||
Status.Visibility.DIRECT -> R.drawable.ic_email_24dp
|
||||
Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp
|
||||
else -> R.drawable.ic_lock_open_24dp
|
||||
}
|
||||
val drawable = ThemeUtils.getTintedDrawable(this, iconRes, android.R.attr.textColorTertiary)
|
||||
composeToggleVisibilityButton.setImageDrawable(drawable)
|
||||
}
|
||||
|
||||
private fun showComposeOptions() {
|
||||
if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_HIDDEN || composeOptionsBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
} else {
|
||||
composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onScheduleClick() {
|
||||
if(viewModel.scheduledAt.value == null) {
|
||||
composeScheduleView.openPickDateDialog()
|
||||
} else {
|
||||
showScheduleView()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showScheduleView() {
|
||||
if (scheduleBehavior.state == BottomSheetBehavior.STATE_HIDDEN || scheduleBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
scheduleBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
} else {
|
||||
scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showEmojis() {
|
||||
emojiView.adapter?.let {
|
||||
if (it.itemCount == 0) {
|
||||
val errorMessage = getString(R.string.error_no_custom_emojis, accountManager.activeAccount!!.domain)
|
||||
Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
if (emojiBehavior.state == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
} else {
|
||||
emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPickDialog() {
|
||||
if (addMediaBehavior.state == BottomSheetBehavior.STATE_HIDDEN || addMediaBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
} else {
|
||||
addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onMediaPick() {
|
||||
addMediaBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
//Wait until bottom sheet is not collapsed and show next screen after
|
||||
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
addMediaBehavior.removeBottomSheetCallback(this)
|
||||
if (ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
ActivityCompat.requestPermissions(this@ComposeActivity,
|
||||
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
|
||||
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE)
|
||||
} else {
|
||||
initiateMediaPicking()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
|
||||
}
|
||||
)
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
|
||||
private fun openPollDialog() {
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
val instanceParams = viewModel.instanceParams.value!!
|
||||
showAddPollDialog(this, viewModel.poll.value, instanceParams.pollMaxOptions,
|
||||
instanceParams.pollMaxLength, viewModel::updatePoll)
|
||||
}
|
||||
|
||||
private fun setupPollView() {
|
||||
val margin = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin)
|
||||
val marginBottom = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom)
|
||||
|
||||
val layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
layoutParams.setMargins(margin, margin, margin, marginBottom)
|
||||
pollPreview.layoutParams = layoutParams
|
||||
|
||||
pollPreview.setOnClickListener {
|
||||
val popup = PopupMenu(this, pollPreview)
|
||||
val editId = 1
|
||||
val removeId = 2
|
||||
popup.menu.add(0, editId, 0, R.string.edit_poll)
|
||||
popup.menu.add(0, removeId, 0, R.string.action_remove)
|
||||
popup.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
editId -> openPollDialog()
|
||||
removeId -> removePoll()
|
||||
}
|
||||
true
|
||||
}
|
||||
popup.show()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun removePoll() {
|
||||
viewModel.poll.value = null
|
||||
pollPreview.hide()
|
||||
}
|
||||
|
||||
override fun onVisibilityChanged(visibility: Status.Visibility) {
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
viewModel.statusVisibility.value = visibility
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun calculateTextLength(): Int {
|
||||
var offset = 0
|
||||
val urlSpans = composeEditField.urls
|
||||
if (urlSpans != null) {
|
||||
for (span in urlSpans) {
|
||||
offset += max(0, span.url.length - MAXIMUM_URL_LENGTH)
|
||||
}
|
||||
}
|
||||
var length = composeEditField.length() - offset
|
||||
if (viewModel.showContentWarning.value!!) {
|
||||
length += composeContentWarningField.length()
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
private fun updateVisibleCharactersLeft() {
|
||||
composeCharactersLeftView.text = String.format(Locale.getDefault(), "%d", maximumTootCharacters - calculateTextLength())
|
||||
}
|
||||
|
||||
private fun onContentWarningChanged() {
|
||||
val showWarning = composeContentWarningBar.isGone
|
||||
viewModel.showContentWarning.value = showWarning
|
||||
updateVisibleCharactersLeft()
|
||||
}
|
||||
|
||||
private fun onSendClicked() {
|
||||
enableButtons(false)
|
||||
sendStatus()
|
||||
}
|
||||
|
||||
/** This is for the fancy keyboards which can insert images and stuff. */
|
||||
override fun onCommitContent(inputContentInfo: InputContentInfoCompat, flags: Int, opts: Bundle): Boolean {
|
||||
try {
|
||||
currentInputContentInfo?.releasePermission()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "InputContentInfoCompat#releasePermission() failed." + e.message)
|
||||
} finally {
|
||||
currentInputContentInfo = null
|
||||
}
|
||||
|
||||
// Verify the returned content's type is of the correct MIME type
|
||||
val supported = inputContentInfo.description.hasMimeType("image/*")
|
||||
|
||||
return supported && onCommitContentInternal(inputContentInfo, flags)
|
||||
}
|
||||
|
||||
private fun onCommitContentInternal(inputContentInfo: InputContentInfoCompat, flags: Int): Boolean {
|
||||
if (flags and InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION != 0) {
|
||||
try {
|
||||
inputContentInfo.requestPermission()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "InputContentInfoCompat#requestPermission() failed." + e.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the file size before putting handing it off to be put in the queue.
|
||||
pickMedia(inputContentInfo.contentUri)
|
||||
|
||||
currentInputContentInfo = inputContentInfo
|
||||
currentFlags = flags
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun sendStatus() {
|
||||
val contentText = composeEditField.text.toString()
|
||||
var spoilerText = ""
|
||||
if (viewModel.showContentWarning.value!!) {
|
||||
spoilerText = composeContentWarningField.text.toString()
|
||||
}
|
||||
val characterCount = calculateTextLength()
|
||||
if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value!!.isEmpty()) {
|
||||
composeEditField.error = getString(R.string.error_empty)
|
||||
enableButtons(true)
|
||||
} else if (characterCount <= maximumTootCharacters) {
|
||||
finishingUploadDialog = ProgressDialog.show(
|
||||
this, getString(R.string.dialog_title_finishing_media_upload),
|
||||
getString(R.string.dialog_message_uploading_media), true, true)
|
||||
|
||||
viewModel.sendStatus(contentText, spoilerText).observe(this, Observer {
|
||||
finishingUploadDialog?.dismiss()
|
||||
finishWithoutSlideOutAnimation()
|
||||
})
|
||||
|
||||
} else {
|
||||
composeEditField.error = getString(R.string.error_compose_character_limit)
|
||||
enableButtons(true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>,
|
||||
grantResults: IntArray) {
|
||||
if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) {
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
initiateMediaPicking()
|
||||
} else {
|
||||
val bar = Snackbar.make(activityCompose, R.string.error_media_upload_permission,
|
||||
Snackbar.LENGTH_SHORT).apply {
|
||||
|
||||
}
|
||||
bar.setAction(R.string.action_retry) { onMediaPick()}
|
||||
//necessary so snackbar is shown over everything
|
||||
bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
|
||||
bar.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initiateCameraApp() {
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
|
||||
// We don't need to ask for permission in this case, because the used calls require
|
||||
// android.permission.WRITE_EXTERNAL_STORAGE only on SDKs *older* than Kitkat, which was
|
||||
// way before permission dialogues have been introduced.
|
||||
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
||||
if (intent.resolveActivity(packageManager) != null) {
|
||||
val photoFile: File = try {
|
||||
createNewImageFile(this)
|
||||
} catch (ex: IOException) {
|
||||
displayTransientError(R.string.error_media_upload_opening)
|
||||
return
|
||||
}
|
||||
|
||||
// Continue only if the File was successfully created
|
||||
photoUploadUri = FileProvider.getUriForFile(this,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
photoFile)
|
||||
intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUploadUri)
|
||||
startActivityForResult(intent, MEDIA_TAKE_PHOTO_RESULT)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initiateMediaPicking() {
|
||||
val intent = Intent(Intent.ACTION_GET_CONTENT)
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
|
||||
val mimeTypes = arrayOf("image/*", "video/*")
|
||||
intent.type = "*/*"
|
||||
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
|
||||
startActivityForResult(intent, MEDIA_PICK_RESULT)
|
||||
}
|
||||
|
||||
private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) {
|
||||
button.isEnabled = clickable
|
||||
ThemeUtils.setDrawableTint(this, button.drawable,
|
||||
if (colorActive) android.R.attr.textColorTertiary
|
||||
else R.attr.image_button_disabled_tint)
|
||||
}
|
||||
|
||||
private fun enablePollButton(enable: Boolean) {
|
||||
addPollTextActionTextView.isEnabled = enable
|
||||
val textColor = ThemeUtils.getColor(this,
|
||||
if (enable) android.R.attr.textColorTertiary
|
||||
else R.attr.image_button_disabled_tint)
|
||||
addPollTextActionTextView.setTextColor(textColor)
|
||||
addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
|
||||
private fun removeMediaFromQueue(item: QueuedMedia) {
|
||||
viewModel.removeMediaFromQueue(item)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, intent)
|
||||
if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_PICK_RESULT && intent != null) {
|
||||
pickMedia(intent.data!!)
|
||||
} else if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) {
|
||||
pickMedia(photoUploadUri!!)
|
||||
}
|
||||
}
|
||||
|
||||
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 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(composeContentWarningBar.parent as ViewGroup)
|
||||
@ColorInt val color = if (show) {
|
||||
composeContentWarningBar.show()
|
||||
composeContentWarningField.setSelection(composeContentWarningField.text.length)
|
||||
composeContentWarningField.requestFocus()
|
||||
ContextCompat.getColor(this, R.color.tusky_blue)
|
||||
} else {
|
||||
composeContentWarningBar.hide()
|
||||
composeEditField.requestFocus()
|
||||
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
}
|
||||
composeContentWarningButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == android.R.id.home) {
|
||||
handleCloseButton()
|
||||
return true
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
// Acting like a teen: deliberately ignoring parent.
|
||||
if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED) {
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
return
|
||||
}
|
||||
|
||||
handleCloseButton()
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
Log.d(TAG, event.toString())
|
||||
if (event.isCtrlPressed) {
|
||||
if (keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||
// send toot by pressing CTRL + ENTER
|
||||
this.onSendClicked()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK) {
|
||||
onBackPressed()
|
||||
return true
|
||||
}
|
||||
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
private fun handleCloseButton() {
|
||||
val contentText = composeEditField.text.toString()
|
||||
val contentWarning = composeContentWarningField.text.toString()
|
||||
if (viewModel.didChange(contentText, contentWarning)) {
|
||||
AlertDialog.Builder(this)
|
||||
.setMessage(R.string.compose_save_draft)
|
||||
.setPositiveButton(R.string.action_save) { _, _ ->
|
||||
saveDraftAndFinish(contentText, contentWarning)
|
||||
}
|
||||
.setNegativeButton(R.string.action_delete) { _, _ -> deleteDraftAndFinish() }
|
||||
.show()
|
||||
} else {
|
||||
finishWithoutSlideOutAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteDraftAndFinish() {
|
||||
viewModel.deleteDraft()
|
||||
finishWithoutSlideOutAnimation()
|
||||
}
|
||||
|
||||
private fun saveDraftAndFinish(contentText: String, contentWarning: String) {
|
||||
viewModel.saveDraft(contentText, contentWarning)
|
||||
finishWithoutSlideOutAnimation()
|
||||
}
|
||||
|
||||
override fun search(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
return viewModel.searchAutocompleteSuggestions(token)
|
||||
}
|
||||
|
||||
override fun onEmojiSelected(shortcode: String) {
|
||||
replaceTextAtCaret(":$shortcode: ")
|
||||
}
|
||||
|
||||
private fun setEmojiList(emojiList: List<Emoji>?) {
|
||||
if (emojiList != null) {
|
||||
emojiView.adapter = EmojiAdapter(emojiList, this@ComposeActivity)
|
||||
enableButton(composeEmojiButton, true, emojiList.isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
data class QueuedMedia(
|
||||
val localId: Long,
|
||||
val uri: Uri,
|
||||
val type: Type,
|
||||
val mediaSize: Long,
|
||||
val uploadPercent: Int = 0,
|
||||
val id: String? = null,
|
||||
val description: String? = null
|
||||
) {
|
||||
enum class Type {
|
||||
IMAGE, VIDEO;
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTimeSet(view: TimePicker, hourOfDay: Int, minute: Int) {
|
||||
composeScheduleView.onTimeSet(hourOfDay, minute)
|
||||
viewModel.updateScheduledAt(composeScheduleView.time)
|
||||
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
}
|
||||
|
||||
private fun resetSchedule() {
|
||||
viewModel.updateScheduledAt(null)
|
||||
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class ComposeOptions(
|
||||
// Let's keep fields var until all consumers are Kotlin
|
||||
var savedTootUid: Int? = null,
|
||||
var tootText: String? = null,
|
||||
var mediaUrls: List<String>? = null,
|
||||
var mediaDescriptions: List<String>? = null,
|
||||
var mentionedUsernames: Set<String>? = null,
|
||||
var inReplyToId: String? = null,
|
||||
var replyVisibility: Status.Visibility? = null,
|
||||
var visibility: Status.Visibility? = null,
|
||||
var contentWarning: String? = null,
|
||||
var replyingStatusAuthor: String? = null,
|
||||
var replyingStatusContent: String? = null,
|
||||
var mediaAttachments: List<Attachment>? = null,
|
||||
var scheduledAt: String? = null,
|
||||
var sensitive: Boolean? = null,
|
||||
var poll: NewPoll? = null
|
||||
) : Parcelable
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ComposeActivity" // logging tag
|
||||
private const val MEDIA_PICK_RESULT = 1
|
||||
private const val MEDIA_TAKE_PHOTO_RESULT = 2
|
||||
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
|
||||
|
||||
private const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"
|
||||
|
||||
// Mastodon only counts URLs as this long in terms of status character limits
|
||||
@VisibleForTesting
|
||||
const val MAXIMUM_URL_LENGTH = 23
|
||||
|
||||
@JvmStatic
|
||||
fun startIntent(context: Context, options: ComposeOptions): Intent {
|
||||
return Intent(context, ComposeActivity::class.java).apply {
|
||||
putExtra(COMPOSE_OPTIONS_EXTRA, options)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun canHandleMimeType(mimeType: String?): Boolean {
|
||||
return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType == "text/plain")
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,467 @@
|
||||
/* Copyright 2019 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.net.Uri
|
||||
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 com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||
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.*
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.service.ServiceClient
|
||||
import com.keylesspalace.tusky.service.TootToSend
|
||||
import com.keylesspalace.tusky.util.*
|
||||
import io.reactivex.disposables.CompositeDisposable
|
||||
import io.reactivex.disposables.Disposable
|
||||
import io.reactivex.rxkotlin.Singles
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
|
||||
open class RxAwareViewModel : ViewModel() {
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
fun Disposable.autoDispose() = disposables.add(this)
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
disposables.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw when trying to add an image when video is already present or the other way around
|
||||
*/
|
||||
class VideoOrImageException : Exception()
|
||||
|
||||
|
||||
class ComposeViewModel
|
||||
@Inject constructor(
|
||||
private val api: MastodonApi,
|
||||
private val accountManager: AccountManager,
|
||||
private val mediaUploader: MediaUploader,
|
||||
private val serviceClient: ServiceClient,
|
||||
private val saveTootHelper: SaveTootHelper,
|
||||
private val db: AppDatabase
|
||||
) : RxAwareViewModel() {
|
||||
|
||||
private var replyingStatusAuthor: String? = null
|
||||
private var replyingStatusContent: String? = null
|
||||
internal var startingText: String? = null
|
||||
private var savedTootUid: Int = 0
|
||||
private var startingContentWarning: String? = null
|
||||
private var inReplyToId: String? = null
|
||||
private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN
|
||||
|
||||
private val instance: MutableLiveData<InstanceEntity?> = 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,
|
||||
supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false
|
||||
)
|
||||
}
|
||||
val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData()
|
||||
val markMediaAsSensitive =
|
||||
mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
|
||||
|
||||
fun toggleMarkSensitive() {
|
||||
this.markMediaAsSensitive.value = !this.markMediaAsSensitive.value!!
|
||||
}
|
||||
|
||||
val statusVisibility = mutableLiveData(Status.Visibility.UNKNOWN)
|
||||
val showContentWarning = mutableLiveData(false)
|
||||
val poll: MutableLiveData<NewPoll?> = mutableLiveData(null)
|
||||
val scheduledAt: MutableLiveData<String?> = mutableLiveData(null)
|
||||
|
||||
val media = mutableLiveData<List<QueuedMedia>>(listOf())
|
||||
val uploadError = MutableLiveData<Throwable>()
|
||||
|
||||
private val mediaToDisposable = mutableMapOf<Long, Disposable>()
|
||||
|
||||
|
||||
init {
|
||||
|
||||
Singles.zip(api.getCustomEmojis(), api.getInstance()) { emojis, instance ->
|
||||
InstanceEntity(
|
||||
instance = accountManager.activeAccount?.domain!!,
|
||||
emojiList = emojis,
|
||||
maximumTootCharacters = instance.maxTootChars,
|
||||
maxPollOptions = instance.pollLimits?.maxOptions,
|
||||
maxPollOptionLength = instance.pollLimits?.maxOptionChars,
|
||||
version = instance.version
|
||||
)
|
||||
}
|
||||
.doOnSuccess {
|
||||
db.instanceDao().insertOrReplace(it)
|
||||
}
|
||||
.onErrorResumeNext(
|
||||
db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
|
||||
)
|
||||
.subscribe ({ instanceEntity ->
|
||||
emoji.postValue(instanceEntity.emojiList)
|
||||
instance.postValue(instanceEntity)
|
||||
}, { throwable ->
|
||||
// this can happen on network error when no cached data is available
|
||||
Log.w(TAG, "error loading instance data", throwable)
|
||||
})
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
fun pickMedia(uri: Uri): 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!!
|
||||
if (type == QueuedMedia.Type.VIDEO
|
||||
&& mediaItems.isNotEmpty()
|
||||
&& mediaItems[0].type == QueuedMedia.Type.IMAGE) {
|
||||
throw VideoOrImageException()
|
||||
} else {
|
||||
addMediaToQueue(type, uri, size)
|
||||
}
|
||||
}
|
||||
.subscribe({ queuedMedia ->
|
||||
liveData.postValue(Either.Right(queuedMedia))
|
||||
}, { error ->
|
||||
liveData.postValue(Either.Left(error))
|
||||
})
|
||||
.autoDispose()
|
||||
return liveData
|
||||
}
|
||||
|
||||
private fun addMediaToQueue(type: QueuedMedia.Type, uri: Uri, mediaSize: Long): QueuedMedia {
|
||||
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, mediaSize)
|
||||
media.value = media.value!! + mediaItem
|
||||
mediaToDisposable[mediaItem.localId] = mediaUploader
|
||||
.uploadMedia(mediaItem)
|
||||
.subscribe ({ event ->
|
||||
val item = media.value?.find { it.localId == mediaItem.localId }
|
||||
?: return@subscribe
|
||||
val newMediaItem = when (event) {
|
||||
is UploadEvent.ProgressEvent ->
|
||||
item.copy(uploadPercent = event.percentage)
|
||||
is UploadEvent.FinishedEvent ->
|
||||
item.copy(id = event.attachment.id, uploadPercent = -1)
|
||||
}
|
||||
synchronized(media) {
|
||||
val mediaValue = media.value!!
|
||||
val index = mediaValue.indexOfFirst { it.localId == newMediaItem.localId }
|
||||
media.postValue(if (index == -1) {
|
||||
mediaValue + newMediaItem
|
||||
} else {
|
||||
mediaValue.toMutableList().also { it[index] = newMediaItem }
|
||||
})
|
||||
}
|
||||
}, { 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
|
||||
}
|
||||
|
||||
fun removeMediaFromQueue(item: QueuedMedia) {
|
||||
mediaToDisposable[item.localId]?.dispose()
|
||||
media.value = media.value!!.withoutFirstWhich { it.localId == item.localId }
|
||||
}
|
||||
|
||||
fun didChange(content: String?, contentWarning: String?): Boolean {
|
||||
|
||||
val textChanged = !(content.isNullOrEmpty()
|
||||
|| startingText?.startsWith(content.toString()) ?: false)
|
||||
|
||||
val contentWarningChanged = showContentWarning.value!!
|
||||
&& !contentWarning.isNullOrEmpty()
|
||||
&& !startingContentWarning!!.startsWith(contentWarning.toString())
|
||||
val mediaChanged = media.value!!.isNotEmpty()
|
||||
val pollChanged = poll.value != null
|
||||
|
||||
return textChanged || contentWarningChanged || mediaChanged || pollChanged
|
||||
}
|
||||
|
||||
fun deleteDraft() {
|
||||
saveTootHelper.deleteDraft(this.savedTootUid)
|
||||
}
|
||||
|
||||
fun saveDraft(content: String, contentWarning: String) {
|
||||
val mediaUris = mutableListOf<String>()
|
||||
val mediaDescriptions = mutableListOf<String?>()
|
||||
for (item in media.value!!) {
|
||||
mediaUris.add(item.uri.toString())
|
||||
mediaDescriptions.add(item.description)
|
||||
}
|
||||
saveTootHelper.saveToot(
|
||||
content,
|
||||
contentWarning,
|
||||
null,
|
||||
mediaUris,
|
||||
mediaDescriptions,
|
||||
savedTootUid,
|
||||
inReplyToId,
|
||||
replyingStatusContent,
|
||||
replyingStatusAuthor,
|
||||
statusVisibility.value!!,
|
||||
poll.value
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send status to the server.
|
||||
* Uses current state plus provided arguments.
|
||||
* @return LiveData which will signal once the screen can be closed or null if there are errors
|
||||
*/
|
||||
fun sendStatus(
|
||||
content: String,
|
||||
spoilerText: String
|
||||
): LiveData<Unit> {
|
||||
return 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!!) {
|
||||
mediaIds.add(item.id!!)
|
||||
mediaUris.add(item.uri)
|
||||
mediaDescriptions.add(item.description ?: "")
|
||||
}
|
||||
|
||||
val tootToSend = TootToSend(
|
||||
content,
|
||||
spoilerText,
|
||||
statusVisibility.value!!.serverString(),
|
||||
mediaUris.isNotEmpty() && markMediaAsSensitive.value!!,
|
||||
mediaIds,
|
||||
mediaUris.map { it.toString() },
|
||||
mediaDescriptions,
|
||||
scheduledAt = scheduledAt.value,
|
||||
inReplyToId = null,
|
||||
poll = poll.value,
|
||||
replyingStatusContent = null,
|
||||
replyingStatusAuthorUsername = null,
|
||||
savedJsonUrls = null,
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
savedTootUid = 0,
|
||||
idempotencyKey = randomAlphanumericString(16),
|
||||
retries = 0
|
||||
)
|
||||
serviceClient.sendToot(tootToSend)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
return completedCaptioningLiveData
|
||||
}
|
||||
|
||||
|
||||
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
when (token[0]) {
|
||||
'@' -> {
|
||||
return try {
|
||||
api.searchAccounts(query = token.substring(1), limit = 10)
|
||||
.blockingGet()
|
||||
.map { ComposeAutoCompleteAdapter.AccountResult(it) }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
'#' -> {
|
||||
return try {
|
||||
api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
|
||||
.blockingGet()
|
||||
.hashtags
|
||||
.map { ComposeAutoCompleteAdapter.HashtagResult(it) }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
':' -> {
|
||||
val emojiList = emoji.value ?: return emptyList()
|
||||
|
||||
val incomplete = token.substring(1).toLowerCase(Locale.ROOT)
|
||||
val results = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
|
||||
val resultsInside = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
|
||||
for (emoji in emojiList) {
|
||||
val shortcode = emoji.shortcode.toLowerCase(Locale.ROOT)
|
||||
if (shortcode.startsWith(incomplete)) {
|
||||
results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
|
||||
} else if (shortcode.indexOf(incomplete, 1) != -1) {
|
||||
resultsInside.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
|
||||
}
|
||||
}
|
||||
if (results.isNotEmpty() && resultsInside.isNotEmpty()) {
|
||||
results.add(ComposeAutoCompleteAdapter.ResultSeparator())
|
||||
}
|
||||
results.addAll(resultsInside)
|
||||
return results
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unexpected autocompletion token: $token")
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
for (uploadDisposable in mediaToDisposable.values) {
|
||||
uploadDisposable.dispose()
|
||||
}
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
fun setup(composeOptions: ComposeActivity.ComposeOptions?) {
|
||||
val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy
|
||||
|
||||
val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN
|
||||
startingVisibility = Status.Visibility.byNum(
|
||||
preferredVisibility.num.coerceAtLeast(replyVisibility.num))
|
||||
statusVisibility.value = startingVisibility
|
||||
|
||||
inReplyToId = composeOptions?.inReplyToId
|
||||
|
||||
|
||||
val contentWarning = composeOptions?.contentWarning
|
||||
if (contentWarning != null) {
|
||||
startingContentWarning = contentWarning
|
||||
}
|
||||
|
||||
// recreate media list
|
||||
// when coming from SavedTootActivity
|
||||
val loadedDraftMediaUris = composeOptions?.mediaUrls
|
||||
val loadedDraftMediaDescriptions: List<String?>? = composeOptions?.mediaDescriptions
|
||||
if (loadedDraftMediaUris != null && loadedDraftMediaDescriptions != null) {
|
||||
loadedDraftMediaUris.zip(loadedDraftMediaDescriptions)
|
||||
.forEach { (uri, description) ->
|
||||
pickMedia(uri.toUri()).observeForever { errorOrItem ->
|
||||
if (errorOrItem.isRight() && description != null) {
|
||||
updateDescription(errorOrItem.asRight().localId, description)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else composeOptions?.mediaAttachments?.forEach { a ->
|
||||
// when coming from redraft
|
||||
val mediaType = when (a.type) {
|
||||
Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO
|
||||
Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE
|
||||
else -> QueuedMedia.Type.IMAGE
|
||||
}
|
||||
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description)
|
||||
}
|
||||
|
||||
|
||||
composeOptions?.savedTootUid?.let { uid ->
|
||||
this.savedTootUid = uid
|
||||
startingText = composeOptions.tootText
|
||||
}
|
||||
|
||||
val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN
|
||||
if (tootVisibility.num != Status.Visibility.UNKNOWN.num) {
|
||||
startingVisibility = tootVisibility
|
||||
}
|
||||
val mentionedUsernames = composeOptions?.mentionedUsernames
|
||||
if (mentionedUsernames != null) {
|
||||
val builder = StringBuilder()
|
||||
for (name in mentionedUsernames) {
|
||||
builder.append('@')
|
||||
builder.append(name)
|
||||
builder.append(' ')
|
||||
}
|
||||
startingText = builder.toString()
|
||||
}
|
||||
|
||||
|
||||
scheduledAt.value = composeOptions?.scheduledAt
|
||||
|
||||
composeOptions?.sensitive?.let { markMediaAsSensitive.value = it }
|
||||
|
||||
val poll = composeOptions?.poll
|
||||
if (poll != null && composeOptions.mediaAttachments.isNullOrEmpty()) {
|
||||
this.poll.value = poll
|
||||
}
|
||||
replyingStatusContent = composeOptions?.replyingStatusContent
|
||||
replyingStatusAuthor = composeOptions?.replyingStatusAuthor
|
||||
}
|
||||
|
||||
fun updatePoll(newPoll: NewPoll) {
|
||||
poll.value = newPoll
|
||||
}
|
||||
|
||||
fun updateScheduledAt(newScheduledAt: String?) {
|
||||
scheduledAt.value = newScheduledAt
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ComposeViewModel"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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 = 25
|
||||
|
||||
data class ComposeInstanceParams(
|
||||
val maxChars: Int,
|
||||
val pollMaxOptions: Int,
|
||||
val pollMaxLength: Int,
|
||||
val supportsScheduled: Boolean
|
||||
)
|
@ -13,7 +13,7 @@
|
||||
* 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;
|
||||
package com.keylesspalace.tusky.components.compose;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.graphics.Bitmap;
|
||||
@ -21,6 +21,8 @@ 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;
|
||||
@ -56,6 +58,25 @@ public class DownsizeImageTask extends AsyncTask<Uri, Void, Boolean> {
|
||||
|
||||
@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 {
|
||||
@ -118,27 +139,16 @@ public class DownsizeImageTask extends AsyncTask<Uri, Void, Boolean> {
|
||||
reorientedBitmap.recycle();
|
||||
scaledImageSize /= 2;
|
||||
} while (tempFile.length() > sizeLimit);
|
||||
|
||||
if (isCancelled()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Boolean successful) {
|
||||
if (successful) {
|
||||
listener.onSuccess(tempFile);
|
||||
} else {
|
||||
listener.onFailure();
|
||||
}
|
||||
super.onPostExecute(successful);
|
||||
}
|
||||
|
||||
/** Used to communicate the results of the task. */
|
||||
/**
|
||||
* Used to communicate the results of the task.
|
||||
*/
|
||||
public interface Listener {
|
||||
void onSuccess(File file);
|
||||
|
||||
void onFailure();
|
||||
}
|
||||
}
|
@ -0,0 +1,105 @@
|
||||
/* Copyright 2019 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.Context
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.PopupMenu
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.recyclerview.widget.AsyncListDiffer
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.compose.view.ProgressImageView
|
||||
|
||||
class MediaPreviewAdapter(
|
||||
context: Context,
|
||||
private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit,
|
||||
private val onRemove: (ComposeActivity.QueuedMedia) -> Unit
|
||||
) : RecyclerView.Adapter<MediaPreviewAdapter.PreviewViewHolder>() {
|
||||
|
||||
fun submitList(list: List<ComposeActivity.QueuedMedia>) {
|
||||
this.differ.submitList(list)
|
||||
}
|
||||
|
||||
private fun onMediaClick(position: Int, view: View) {
|
||||
val item = differ.currentList[position]
|
||||
val popup = PopupMenu(view.context, view)
|
||||
val addCaptionId = 1
|
||||
val removeId = 2
|
||||
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
|
||||
popup.menu.add(0, removeId, 0, R.string.action_remove)
|
||||
popup.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
addCaptionId -> onAddCaption(item)
|
||||
removeId -> onRemove(item)
|
||||
}
|
||||
true
|
||||
}
|
||||
popup.show()
|
||||
}
|
||||
|
||||
private val thumbnailViewSize =
|
||||
context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size)
|
||||
|
||||
override fun getItemCount(): Int = differ.currentList.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PreviewViewHolder {
|
||||
return PreviewViewHolder(ProgressImageView(parent.context))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: PreviewViewHolder, position: Int) {
|
||||
val item = differ.currentList[position]
|
||||
holder.progressImageView.setChecked(!item.description.isNullOrEmpty())
|
||||
holder.progressImageView.setProgress(item.uploadPercent)
|
||||
Glide.with(holder.itemView.context)
|
||||
.load(item.uri)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.dontAnimate()
|
||||
.into(holder.progressImageView)
|
||||
}
|
||||
|
||||
private val differ = AsyncListDiffer(this, object : DiffUtil.ItemCallback<ComposeActivity.QueuedMedia>() {
|
||||
override fun areItemsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
|
||||
return oldItem.localId == newItem.localId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
})
|
||||
|
||||
inner class PreviewViewHolder(val progressImageView: ProgressImageView)
|
||||
: RecyclerView.ViewHolder(progressImageView) {
|
||||
init {
|
||||
val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize)
|
||||
val margin = itemView.context.resources
|
||||
.getDimensionPixelSize(R.dimen.compose_media_preview_margin)
|
||||
val marginBottom = itemView.context.resources
|
||||
.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom)
|
||||
layoutParams.setMargins(margin, 0, margin, marginBottom)
|
||||
progressImageView.layoutParams = layoutParams
|
||||
progressImageView.scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
progressImageView.setOnClickListener {
|
||||
onMediaClick(adapterPosition, progressImageView)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,203 @@
|
||||
/* Copyright 2019 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.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toUri
|
||||
import com.keylesspalace.tusky.BuildConfig
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.ProgressRequestBody
|
||||
import com.keylesspalace.tusky.util.*
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.Single
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
|
||||
sealed class UploadEvent {
|
||||
data class ProgressEvent(val percentage: Int) : UploadEvent()
|
||||
data class FinishedEvent(val attachment: Attachment) : UploadEvent()
|
||||
}
|
||||
|
||||
fun createNewImageFile(context: Context): File {
|
||||
// Create an image file name
|
||||
val randomId = randomAlphanumericString(12)
|
||||
val imageFileName = "Tusky_${randomId}_"
|
||||
val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
|
||||
return File.createTempFile(
|
||||
imageFileName, /* prefix */
|
||||
".jpg", /* suffix */
|
||||
storageDir /* directory */
|
||||
)
|
||||
}
|
||||
|
||||
data class PreparedMedia(val type: QueuedMedia.Type, val uri: Uri, val size: Long)
|
||||
|
||||
interface MediaUploader {
|
||||
fun prepareMedia(inUri: Uri): Single<PreparedMedia>
|
||||
fun uploadMedia(media: QueuedMedia): Observable<UploadEvent>
|
||||
}
|
||||
|
||||
class VideoSizeException : Exception()
|
||||
class MediaTypeException : Exception()
|
||||
class CouldNotOpenFileException : Exception()
|
||||
|
||||
class MediaUploaderImpl(
|
||||
private val context: Context,
|
||||
private val mastodonApi: MastodonApi
|
||||
) : MediaUploader {
|
||||
override fun uploadMedia(media: QueuedMedia): Observable<UploadEvent> {
|
||||
return Observable
|
||||
.fromCallable {
|
||||
if (shouldResizeMedia(media)) {
|
||||
downsize(media)
|
||||
}
|
||||
media
|
||||
}
|
||||
.switchMap { upload(it) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
override fun prepareMedia(inUri: Uri): Single<PreparedMedia> {
|
||||
return Single.fromCallable {
|
||||
var mediaSize = getMediaSize(contentResolver, inUri)
|
||||
var uri = inUri
|
||||
val mimeType = contentResolver.getType(uri)
|
||||
|
||||
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
|
||||
|
||||
try {
|
||||
contentResolver.openInputStream(inUri).use { input ->
|
||||
if (input == null) {
|
||||
Log.w(TAG, "Media input is null")
|
||||
uri = inUri
|
||||
return@use
|
||||
}
|
||||
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
|
||||
FileOutputStream(file.absoluteFile).use { out ->
|
||||
input.copyTo(out)
|
||||
uri = FileProvider.getUriForFile(context,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
file)
|
||||
mediaSize = getMediaSize(contentResolver, uri)
|
||||
}
|
||||
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
uri = inUri
|
||||
}
|
||||
if (mediaSize == MEDIA_SIZE_UNKNOWN) {
|
||||
throw CouldNotOpenFileException()
|
||||
}
|
||||
|
||||
if (mimeType != null) {
|
||||
val topLevelType = mimeType.substring(0, mimeType.indexOf('/'))
|
||||
when (topLevelType) {
|
||||
"video" -> {
|
||||
if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) {
|
||||
throw VideoSizeException()
|
||||
}
|
||||
PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize)
|
||||
}
|
||||
"image" -> {
|
||||
PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
|
||||
}
|
||||
else -> {
|
||||
throw MediaTypeException()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw MediaTypeException()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val contentResolver = context.contentResolver
|
||||
|
||||
private fun upload(media: QueuedMedia): Observable<UploadEvent> {
|
||||
return Observable.create { emitter ->
|
||||
var mimeType = contentResolver.getType(media.uri)
|
||||
val map = MimeTypeMap.getSingleton()
|
||||
val fileExtension = map.getExtensionFromMimeType(mimeType)
|
||||
val filename = String.format("%s_%s_%s.%s",
|
||||
context.getString(R.string.app_name),
|
||||
Date().time.toString(),
|
||||
randomAlphanumericString(10),
|
||||
fileExtension)
|
||||
|
||||
val stream = contentResolver.openInputStream(media.uri)
|
||||
|
||||
if (mimeType == null) mimeType = "multipart/form-data"
|
||||
|
||||
|
||||
var lastProgress = -1
|
||||
val fileBody = ProgressRequestBody(stream, media.mediaSize,
|
||||
mimeType.toMediaTypeOrNull()) { percentage ->
|
||||
if (percentage != lastProgress) {
|
||||
emitter.onNext(UploadEvent.ProgressEvent(percentage))
|
||||
}
|
||||
lastProgress = percentage
|
||||
}
|
||||
|
||||
val body = MultipartBody.Part.createFormData("file", filename, fileBody)
|
||||
|
||||
val uploadDisposable = mastodonApi.uploadMedia(body)
|
||||
.subscribe({ attachment ->
|
||||
emitter.onNext(UploadEvent.FinishedEvent(attachment))
|
||||
emitter.onComplete()
|
||||
}, { e ->
|
||||
emitter.onError(e)
|
||||
})
|
||||
|
||||
// Cancel the request when our observable is cancelled
|
||||
emitter.setDisposable(uploadDisposable)
|
||||
}
|
||||
}
|
||||
|
||||
private fun downsize(media: QueuedMedia): QueuedMedia {
|
||||
val file = createNewImageFile(context)
|
||||
DownsizeImageTask.resize(arrayOf(media.uri),
|
||||
STATUS_IMAGE_SIZE_LIMIT, context.contentResolver, file)
|
||||
return media.copy(uri = file.toUri(), mediaSize = file.length())
|
||||
}
|
||||
|
||||
private fun shouldResizeMedia(media: QueuedMedia): Boolean {
|
||||
return media.type == QueuedMedia.Type.IMAGE
|
||||
&& (media.mediaSize > STATUS_IMAGE_SIZE_LIMIT
|
||||
|| getImageSquarePixels(context.contentResolver, media.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "MediaUploaderImpl"
|
||||
private const val STATUS_VIDEO_SIZE_LIMIT = 41943040 // 40MiB
|
||||
private const val STATUS_IMAGE_SIZE_LIMIT = 8388608 // 8MiB
|
||||
private const val STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216 // 4096^2 Pixels
|
||||
|
||||
}
|
||||
}
|
@ -15,29 +15,28 @@
|
||||
|
||||
@file:JvmName("AddPollDialog")
|
||||
|
||||
package com.keylesspalace.tusky.view
|
||||
package com.keylesspalace.tusky.components.compose.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.WindowManager
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.keylesspalace.tusky.ComposeActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.AddPollOptionsAdapter
|
||||
import com.keylesspalace.tusky.entity.NewPoll
|
||||
import kotlinx.android.synthetic.main.dialog_add_poll.view.*
|
||||
import android.view.WindowManager
|
||||
import com.keylesspalace.tusky.R
|
||||
|
||||
private const val DEFAULT_MAX_OPTION_COUNT = 4
|
||||
private const val DEFAULT_MAX_OPTION_LENGTH = 25
|
||||
|
||||
fun showAddPollDialog(
|
||||
activity: ComposeActivity,
|
||||
context: Context,
|
||||
poll: NewPoll?,
|
||||
maxOptionCount: Int?,
|
||||
maxOptionLength: Int?
|
||||
maxOptionCount: Int,
|
||||
maxOptionLength: Int,
|
||||
onUpdatePoll: (NewPoll) -> Unit
|
||||
) {
|
||||
|
||||
val view = activity.layoutInflater.inflate(R.layout.dialog_add_poll, null)
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_add_poll, null)
|
||||
|
||||
val dialog = AlertDialog.Builder(activity)
|
||||
val dialog = AlertDialog.Builder(context)
|
||||
.setIcon(R.drawable.ic_poll_24dp)
|
||||
.setTitle(R.string.create_poll_title)
|
||||
.setView(view)
|
||||
@ -47,7 +46,7 @@ fun showAddPollDialog(
|
||||
|
||||
val adapter = AddPollOptionsAdapter(
|
||||
options = poll?.options?.toMutableList() ?: mutableListOf("", ""),
|
||||
maxOptionLength = maxOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
|
||||
maxOptionLength = maxOptionLength,
|
||||
onOptionRemoved = { valid ->
|
||||
view.addChoiceButton.isEnabled = true
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid
|
||||
@ -60,15 +59,15 @@ fun showAddPollDialog(
|
||||
view.pollChoices.adapter = adapter
|
||||
|
||||
view.addChoiceButton.setOnClickListener {
|
||||
if (adapter.itemCount < maxOptionCount ?: DEFAULT_MAX_OPTION_COUNT) {
|
||||
if (adapter.itemCount < maxOptionCount) {
|
||||
adapter.addChoice()
|
||||
}
|
||||
if (adapter.itemCount >= maxOptionCount ?: DEFAULT_MAX_OPTION_COUNT) {
|
||||
if (adapter.itemCount >= maxOptionCount) {
|
||||
it.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
val pollDurationId = activity.resources.getIntArray(R.array.poll_duration_values).indexOfLast {
|
||||
val pollDurationId = context.resources.getIntArray(R.array.poll_duration_values).indexOfLast {
|
||||
it <= poll?.expiresIn ?: 0
|
||||
}
|
||||
|
||||
@ -81,15 +80,14 @@ fun showAddPollDialog(
|
||||
button.setOnClickListener {
|
||||
val selectedPollDurationId = view.pollDurationSpinner.selectedItemPosition
|
||||
|
||||
val pollDuration = activity.resources.getIntArray(R.array.poll_duration_values)[selectedPollDurationId]
|
||||
val pollDuration = context.resources
|
||||
.getIntArray(R.array.poll_duration_values)[selectedPollDurationId]
|
||||
|
||||
activity.updatePoll(
|
||||
NewPoll(
|
||||
onUpdatePoll(NewPoll(
|
||||
options = adapter.pollOptions,
|
||||
expiresIn = pollDuration,
|
||||
multiple = view.multipleChoicesCheckBox.isChecked
|
||||
)
|
||||
)
|
||||
))
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
@ -0,0 +1,113 @@
|
||||
/* Copyright 2019 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.dialog
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.DialogInterface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.text.InputFilter
|
||||
import android.text.InputType
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.WindowManager
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LiveData
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.target.CustomTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.util.withLifecycleContext
|
||||
|
||||
// https://github.com/tootsuite/mastodon/blob/1656663/app/models/media_attachment.rb#L94
|
||||
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 420
|
||||
|
||||
|
||||
fun <T> T.makeCaptionDialog(existingDescription: String?,
|
||||
previewUri: Uri,
|
||||
onUpdateDescription: (String) -> LiveData<Boolean>
|
||||
) where T : Activity, T : LifecycleOwner {
|
||||
val dialogLayout = LinearLayout(this)
|
||||
val padding = Utils.dpToPx(this, 8)
|
||||
dialogLayout.setPadding(padding, padding, padding, padding)
|
||||
|
||||
dialogLayout.orientation = LinearLayout.VERTICAL
|
||||
val imageView = ImageView(this)
|
||||
|
||||
val displayMetrics = DisplayMetrics()
|
||||
windowManager.defaultDisplay.getMetrics(displayMetrics)
|
||||
|
||||
val margin = Utils.dpToPx(this, 4)
|
||||
dialogLayout.addView(imageView)
|
||||
(imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f
|
||||
imageView.layoutParams.height = 0
|
||||
(imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0)
|
||||
|
||||
val input = EditText(this)
|
||||
input.hint = getString(R.string.hint_describe_for_visually_impaired,
|
||||
MEDIA_DESCRIPTION_CHARACTER_LIMIT)
|
||||
dialogLayout.addView(input)
|
||||
(input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin)
|
||||
input.setLines(2)
|
||||
input.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
|
||||
input.setText(existingDescription)
|
||||
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() }
|
||||
|
||||
}
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
val dialog = AlertDialog.Builder(this)
|
||||
.setView(dialogLayout)
|
||||
.setPositiveButton(android.R.string.ok, okListener)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.create()
|
||||
|
||||
val window = dialog.window
|
||||
window?.setSoftInputMode(
|
||||
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
|
||||
dialog.show()
|
||||
|
||||
// Load the image and manually set it into the ImageView because it doesn't have a fixed
|
||||
// size. Maybe we should limit the size of CustomTarget
|
||||
Glide.with(this)
|
||||
.load(previewUri)
|
||||
.into(object : CustomTarget<Drawable>() {
|
||||
override fun onLoadCleared(placeholder: Drawable?) {}
|
||||
|
||||
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
|
||||
imageView.setImageDrawable(resource)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
private fun Activity.showFailedCaptionMessage() {
|
||||
Toast.makeText(this, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show()
|
||||
}
|
@ -13,7 +13,7 @@
|
||||
* 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.view
|
||||
package com.keylesspalace.tusky.components.compose.view
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
@ -13,7 +13,7 @@
|
||||
* 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.view;
|
||||
package com.keylesspalace.tusky.components.compose.view;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.Drawable;
|
||||
@ -30,6 +30,7 @@ import com.google.android.material.datepicker.DateValidatorPointForward;
|
||||
import com.google.android.material.datepicker.MaterialDatePicker;
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.fragment.TimePickerFragment;
|
||||
import com.keylesspalace.tusky.util.ThemeUtils;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.text.ParseException;
|
||||
@ -87,7 +88,7 @@ public class ComposeScheduleView extends ConstraintLayout {
|
||||
|
||||
private void setScheduledDateTime() {
|
||||
if (scheduleDateTime == null) {
|
||||
scheduledDateTimeView.setText(R.string.hint_configure_scheduled_toot);
|
||||
scheduledDateTimeView.setText("");
|
||||
} else {
|
||||
scheduledDateTimeView.setText(String.format("%s %s",
|
||||
dateFormat.format(scheduleDateTime.getTime()),
|
||||
@ -96,13 +97,13 @@ public class ComposeScheduleView extends ConstraintLayout {
|
||||
}
|
||||
|
||||
private void setEditIcons() {
|
||||
final int size = scheduledDateTimeView.getLineHeight();
|
||||
|
||||
Drawable icon = getContext().getDrawable(R.drawable.ic_create_24dp);
|
||||
Drawable icon = ThemeUtils.getTintedDrawable(getContext(), R.drawable.ic_create_24dp, android.R.attr.textColorTertiary);
|
||||
if (icon == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final int size = scheduledDateTimeView.getLineHeight();
|
||||
|
||||
icon.setBounds(0, 0, size, size);
|
||||
|
||||
scheduledDateTimeView.setCompoundDrawables(null, null, icon, null);
|
||||
@ -117,7 +118,7 @@ public class ComposeScheduleView extends ConstraintLayout {
|
||||
setScheduledDateTime();
|
||||
}
|
||||
|
||||
private void openPickDateDialog() {
|
||||
public void openPickDateDialog() {
|
||||
long yesterday = Calendar.getInstance().getTimeInMillis() - 24 * 60 * 60 * 1000;
|
||||
CalendarConstraints calendarConstraints = new CalendarConstraints.Builder()
|
||||
.setValidator(
|
@ -13,7 +13,7 @@
|
||||
* 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.view
|
||||
package com.keylesspalace.tusky.components.compose.view
|
||||
|
||||
import android.content.Context
|
||||
import androidx.emoji.widget.EmojiEditTextHelper
|
@ -13,7 +13,7 @@
|
||||
* 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.view
|
||||
package com.keylesspalace.tusky.components.compose.view
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
@ -13,7 +13,7 @@
|
||||
* 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.view;
|
||||
package com.keylesspalace.tusky.components.compose.view;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
@ -13,7 +13,7 @@
|
||||
* 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.view
|
||||
package com.keylesspalace.tusky.components.compose.view
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
@ -38,7 +38,12 @@ import androidx.paging.PagedListAdapter
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.keylesspalace.tusky.*
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.MainActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.ViewMediaActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions
|
||||
import com.keylesspalace.tusky.components.report.ReportActivity
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
@ -195,14 +200,14 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
||||
mentionedUsernames.add(username)
|
||||
}
|
||||
mentionedUsernames.remove(loggedInUsername)
|
||||
val intent = ComposeActivity.IntentBuilder()
|
||||
.inReplyToId(inReplyToId)
|
||||
.replyVisibility(replyVisibility)
|
||||
.contentWarning(contentWarning)
|
||||
.mentionedUsernames(mentionedUsernames)
|
||||
.replyingStatusAuthor(actionableStatus.account.localUsername)
|
||||
.replyingStatusContent(actionableStatus.content.toString())
|
||||
.build(context)
|
||||
val intent = ComposeActivity.startIntent(context!!, ComposeOptions(
|
||||
inReplyToId = inReplyToId,
|
||||
replyVisibility = replyVisibility,
|
||||
contentWarning = contentWarning,
|
||||
mentionedUsernames = mentionedUsernames,
|
||||
replyingStatusAuthor = actionableStatus.account.localUsername,
|
||||
replyingStatusContent = actionableStatus.content.toString()
|
||||
))
|
||||
requireActivity().startActivity(intent)
|
||||
}
|
||||
|
||||
@ -407,15 +412,15 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
||||
deletedStatus
|
||||
}
|
||||
|
||||
val intent = ComposeActivity.IntentBuilder()
|
||||
.tootText(redraftStatus.text)
|
||||
.inReplyToId(redraftStatus.inReplyToId)
|
||||
.visibility(redraftStatus.visibility)
|
||||
.contentWarning(redraftStatus.spoilerText)
|
||||
.mediaAttachments(redraftStatus.attachments)
|
||||
.sensitive(redraftStatus.sensitive)
|
||||
.poll(redraftStatus.poll?.toNewPoll(status.createdAt))
|
||||
.build(context)
|
||||
val intent = ComposeActivity.startIntent(context!!, ComposeOptions(
|
||||
tootText = redraftStatus.text ?: "",
|
||||
inReplyToId = redraftStatus.inReplyToId,
|
||||
visibility = redraftStatus.visibility,
|
||||
contentWarning = redraftStatus.spoilerText,
|
||||
mediaAttachments = redraftStatus.attachments,
|
||||
sensitive = redraftStatus.sensitive,
|
||||
poll = redraftStatus.poll?.toNewPoll(status.createdAt)
|
||||
))
|
||||
startActivity(intent)
|
||||
}, { error ->
|
||||
Log.w("SearchStatusesFragment", "error deleting status", error)
|
||||
|
@ -30,7 +30,7 @@ import androidx.annotation.NonNull;
|
||||
|
||||
@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
|
||||
TimelineAccountEntity.class, ConversationEntity.class
|
||||
}, version = 20)
|
||||
}, version = 21)
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
|
||||
public abstract TootDao tootDao();
|
||||
@ -316,6 +316,14 @@ public abstract class AppDatabase extends RoomDatabase {
|
||||
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `bookmarked` INTEGER NOT NULL DEFAULT 0");
|
||||
database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_bookmarked` INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_20_21 = new Migration(20, 21) {
|
||||
@Override
|
||||
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `version` TEXT");
|
||||
}
|
||||
};
|
||||
|
||||
}
|
@ -19,6 +19,7 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import io.reactivex.Single
|
||||
|
||||
@Dao
|
||||
interface InstanceDao {
|
||||
@ -26,5 +27,5 @@ interface InstanceDao {
|
||||
fun insertOrReplace(instance: InstanceEntity)
|
||||
|
||||
@Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1")
|
||||
fun loadMetadataForInstance(instance: String): InstanceEntity?
|
||||
fun loadMetadataForInstance(instance: String): Single<InstanceEntity>
|
||||
}
|
||||
|
@ -27,5 +27,6 @@ data class InstanceEntity(
|
||||
val emojiList: List<Emoji>?,
|
||||
val maximumTootCharacters: Int?,
|
||||
val maxPollOptions: Int?,
|
||||
val maxPollOptionLength: Int?
|
||||
val maxPollOptionLength: Int?,
|
||||
val version: String?
|
||||
)
|
||||
|
@ -16,6 +16,7 @@
|
||||
package com.keylesspalace.tusky.di
|
||||
|
||||
import com.keylesspalace.tusky.*
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
|
||||
import com.keylesspalace.tusky.components.report.ReportActivity
|
||||
import com.keylesspalace.tusky.components.search.SearchActivity
|
||||
|
@ -35,7 +35,8 @@ import javax.inject.Singleton
|
||||
ServicesModule::class,
|
||||
BroadcastReceiverModule::class,
|
||||
ViewModelModule::class,
|
||||
RepositoryModule::class
|
||||
RepositoryModule::class,
|
||||
MediaUploaderModule::class
|
||||
])
|
||||
interface AppComponent {
|
||||
@Component.Builder
|
||||
|
@ -0,0 +1,30 @@
|
||||
/* Copyright 2019 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.di
|
||||
|
||||
import android.content.Context
|
||||
import com.keylesspalace.tusky.components.compose.MediaUploader
|
||||
import com.keylesspalace.tusky.components.compose.MediaUploaderImpl
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
||||
@Module
|
||||
class MediaUploaderModule {
|
||||
@Provides
|
||||
fun providesMediaUploder(context: Context, mastodonApi: MastodonApi): MediaUploader =
|
||||
MediaUploaderImpl(context, mastodonApi)
|
||||
}
|
@ -15,12 +15,25 @@
|
||||
|
||||
package com.keylesspalace.tusky.di
|
||||
|
||||
import android.content.Context
|
||||
import com.keylesspalace.tusky.service.SendTootService
|
||||
import com.keylesspalace.tusky.service.ServiceClient
|
||||
import com.keylesspalace.tusky.service.ServiceClientImpl
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
|
||||
@Module
|
||||
abstract class ServicesModule {
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributesSendTootService(): SendTootService
|
||||
|
||||
@Module
|
||||
companion object {
|
||||
@Provides
|
||||
@JvmStatic
|
||||
fun providesServiceClient(context: Context): ServiceClient {
|
||||
return ServiceClientImpl(context)
|
||||
}
|
||||
}
|
||||
}
|
@ -4,10 +4,13 @@ package com.keylesspalace.tusky.di
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.keylesspalace.tusky.components.compose.ComposeViewModel
|
||||
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
|
||||
import com.keylesspalace.tusky.components.report.ReportViewModel
|
||||
import com.keylesspalace.tusky.components.search.SearchViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.*
|
||||
import com.keylesspalace.tusky.viewmodel.AccountViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.ListsViewModel
|
||||
import dagger.Binds
|
||||
import dagger.MapKey
|
||||
@ -71,5 +74,10 @@ abstract class ViewModelModule {
|
||||
@ViewModelKey(SearchViewModel::class)
|
||||
internal abstract fun searchViewModel(viewModel: SearchViewModel): ViewModel
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(ComposeViewModel::class)
|
||||
internal abstract fun composeViewModel(viewModel: ComposeViewModel): ViewModel
|
||||
|
||||
//Add more ViewModels here
|
||||
}
|
@ -42,12 +42,13 @@ import androidx.lifecycle.Lifecycle;
|
||||
|
||||
import com.keylesspalace.tusky.BaseActivity;
|
||||
import com.keylesspalace.tusky.BottomSheetActivity;
|
||||
import com.keylesspalace.tusky.ComposeActivity;
|
||||
import com.keylesspalace.tusky.MainActivity;
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.PostLookupFallbackBehavior;
|
||||
import com.keylesspalace.tusky.ViewMediaActivity;
|
||||
import com.keylesspalace.tusky.ViewTagActivity;
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity;
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions;
|
||||
import com.keylesspalace.tusky.components.report.ReportActivity;
|
||||
import com.keylesspalace.tusky.db.AccountEntity;
|
||||
import com.keylesspalace.tusky.db.AccountManager;
|
||||
@ -155,14 +156,15 @@ public abstract class SFragment extends BaseFragment implements Injectable {
|
||||
mentionedUsernames.add(mention.getUsername());
|
||||
}
|
||||
mentionedUsernames.remove(loggedInUsername);
|
||||
Intent intent = new ComposeActivity.IntentBuilder()
|
||||
.inReplyToId(inReplyToId)
|
||||
.replyVisibility(replyVisibility)
|
||||
.contentWarning(contentWarning)
|
||||
.mentionedUsernames(mentionedUsernames)
|
||||
.replyingStatusAuthor(actionableStatus.getAccount().getLocalUsername())
|
||||
.replyingStatusContent(actionableStatus.getContent().toString())
|
||||
.build(getContext());
|
||||
ComposeOptions composeOptions = new ComposeOptions();
|
||||
composeOptions.setInReplyToId(inReplyToId);
|
||||
composeOptions.setReplyVisibility(replyVisibility);
|
||||
composeOptions.setContentWarning(contentWarning);
|
||||
composeOptions.setMentionedUsernames(mentionedUsernames);
|
||||
composeOptions.setReplyingStatusAuthor(actionableStatus.getAccount().getLocalUsername());
|
||||
composeOptions.setReplyingStatusContent(actionableStatus.getContent().toString());
|
||||
|
||||
Intent intent = ComposeActivity.startIntent(getContext(), composeOptions);
|
||||
getActivity().startActivity(intent);
|
||||
}
|
||||
|
||||
@ -232,7 +234,8 @@ public abstract class SFragment extends BaseFragment implements Injectable {
|
||||
switch (item.getItemId()) {
|
||||
case R.id.status_share_content: {
|
||||
Status statusToShare = status;
|
||||
if(statusToShare.getReblog() != null) statusToShare = statusToShare.getReblog();
|
||||
if (statusToShare.getReblog() != null)
|
||||
statusToShare = statusToShare.getReblog();
|
||||
|
||||
Intent sendIntent = new Intent();
|
||||
sendIntent.setAction(Intent.ACTION_SEND);
|
||||
@ -357,7 +360,8 @@ public abstract class SFragment extends BaseFragment implements Injectable {
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
|
||||
.subscribe(
|
||||
deletedStatus -> {},
|
||||
deletedStatus -> {
|
||||
},
|
||||
error -> {
|
||||
Log.w("SFragment", "error deleting status", error);
|
||||
Toast.makeText(getContext(), R.string.error_generic, Toast.LENGTH_SHORT).show();
|
||||
@ -384,19 +388,19 @@ public abstract class SFragment extends BaseFragment implements Injectable {
|
||||
if (deletedStatus.isEmpty()) {
|
||||
deletedStatus = status.toDeletedStatus();
|
||||
}
|
||||
|
||||
ComposeActivity.IntentBuilder intentBuilder = new ComposeActivity.IntentBuilder()
|
||||
.tootText(deletedStatus.getText())
|
||||
.inReplyToId(deletedStatus.getInReplyToId())
|
||||
.visibility(deletedStatus.getVisibility())
|
||||
.contentWarning(deletedStatus.getSpoilerText())
|
||||
.mediaAttachments(deletedStatus.getAttachments())
|
||||
.sensitive(deletedStatus.getSensitive());
|
||||
ComposeOptions composeOptions = new ComposeOptions();
|
||||
composeOptions.setTootText(deletedStatus.getText());
|
||||
composeOptions.setInReplyToId(deletedStatus.getInReplyToId());
|
||||
composeOptions.setVisibility(deletedStatus.getVisibility());
|
||||
composeOptions.setContentWarning(deletedStatus.getSpoilerText());
|
||||
composeOptions.setMediaAttachments(deletedStatus.getAttachments());
|
||||
composeOptions.setSensitive(deletedStatus.getSensitive());
|
||||
if (deletedStatus.getPoll() != null) {
|
||||
intentBuilder.poll(deletedStatus.getPoll().toNewPoll(deletedStatus.getCreatedAt()));
|
||||
composeOptions.setPoll(deletedStatus.getPoll().toNewPoll(deletedStatus.getCreatedAt()));
|
||||
}
|
||||
|
||||
Intent intent = intentBuilder.build(getContext());
|
||||
Intent intent = ComposeActivity
|
||||
.startIntent(getContext(), composeOptions);
|
||||
startActivity(intent);
|
||||
},
|
||||
error -> {
|
||||
|
@ -22,7 +22,7 @@ import android.os.Bundle;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
|
||||
import com.keylesspalace.tusky.ComposeActivity;
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity;
|
||||
|
||||
import java.util.Calendar;
|
||||
import java.util.TimeZone;
|
||||
|
@ -43,7 +43,7 @@ interface MastodonApi {
|
||||
fun getLists(): Single<List<MastoList>>
|
||||
|
||||
@GET("/api/v1/custom_emojis")
|
||||
fun getCustomEmojis(): Call<List<Emoji>>
|
||||
fun getCustomEmojis(): Single<List<Emoji>>
|
||||
|
||||
@GET("api/v1/instance")
|
||||
fun getInstance(): Single<Instance>
|
||||
@ -116,14 +116,14 @@ interface MastodonApi {
|
||||
@POST("api/v1/media")
|
||||
fun uploadMedia(
|
||||
@Part file: MultipartBody.Part
|
||||
): Call<Attachment>
|
||||
): Single<Attachment>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT("api/v1/media/{mediaId}")
|
||||
fun updateMedia(
|
||||
@Path("mediaId") mediaId: String,
|
||||
@Field("description") description: String
|
||||
): Call<Attachment>
|
||||
): Single<Attachment>
|
||||
|
||||
@POST("api/v1/statuses")
|
||||
fun createStatus(
|
||||
@ -238,10 +238,10 @@ interface MastodonApi {
|
||||
|
||||
@GET("api/v1/accounts/search")
|
||||
fun searchAccounts(
|
||||
@Query("q") q: String,
|
||||
@Query("resolve") resolve: Boolean?,
|
||||
@Query("limit") limit: Int?,
|
||||
@Query("following") following: Boolean?
|
||||
@Query("q") query: String,
|
||||
@Query("resolve") resolve: Boolean? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("following") following: Boolean? = null
|
||||
): Single<List<Account>>
|
||||
|
||||
@GET("api/v1/accounts/{id}")
|
||||
|
@ -23,12 +23,15 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.RemoteInput
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.keylesspalace.tusky.ComposeActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.service.SendTootService
|
||||
import com.keylesspalace.tusky.service.TootToSend
|
||||
import com.keylesspalace.tusky.util.NotificationHelper
|
||||
import com.keylesspalace.tusky.util.randomAlphanumericString
|
||||
import dagger.android.AndroidInjection
|
||||
import javax.inject.Inject
|
||||
|
||||
@ -85,9 +88,10 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
|
||||
|
||||
val sendIntent = SendTootService.sendTootIntent(
|
||||
context,
|
||||
TootToSend(
|
||||
text,
|
||||
spoiler,
|
||||
visibility,
|
||||
visibility.serverString(),
|
||||
false,
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
@ -97,7 +101,12 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null, account, 0)
|
||||
null, account.id,
|
||||
0,
|
||||
randomAlphanumericString(16),
|
||||
0
|
||||
)
|
||||
)
|
||||
|
||||
context.startService(sendIntent)
|
||||
|
||||
@ -125,14 +134,14 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
|
||||
|
||||
accountManager.setActiveAccount(senderId)
|
||||
|
||||
val composeIntent = ComposeActivity.IntentBuilder()
|
||||
.inReplyToId(citedStatusId)
|
||||
.replyVisibility(visibility)
|
||||
.contentWarning(spoiler)
|
||||
.mentionedUsernames(mentions.toList())
|
||||
.replyingStatusAuthor(localAuthorId)
|
||||
.replyingStatusContent(citedText)
|
||||
.build(context)
|
||||
val composeIntent = ComposeActivity.startIntent(context, ComposeOptions(
|
||||
inReplyToId = citedStatusId,
|
||||
replyVisibility = visibility,
|
||||
contentWarning = spoiler,
|
||||
mentionedUsernames = mentions.toSet(),
|
||||
replyingStatusAuthor = localAuthorId,
|
||||
replyingStatusContent = citedText
|
||||
))
|
||||
|
||||
composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
|
@ -8,7 +8,6 @@ import android.content.ClipData
|
||||
import android.content.ClipDescription
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.Parcelable
|
||||
@ -19,7 +18,6 @@ import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent
|
||||
import com.keylesspalace.tusky.appstore.StatusScheduledEvent
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
@ -28,7 +26,6 @@ import com.keylesspalace.tusky.entity.NewStatus
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.SaveTootHelper
|
||||
import com.keylesspalace.tusky.util.randomAlphanumericString
|
||||
import dagger.android.AndroidInjection
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
import retrofit2.Call
|
||||
@ -50,7 +47,8 @@ class SendTootService : Service(), Injectable {
|
||||
@Inject
|
||||
lateinit var database: AppDatabase
|
||||
|
||||
private lateinit var saveTootHelper: SaveTootHelper
|
||||
@Inject
|
||||
lateinit var saveTootHelper: SaveTootHelper
|
||||
|
||||
private val tootsToSend = ConcurrentHashMap<Int, TootToSend>()
|
||||
private val sendCalls = ConcurrentHashMap<Int, Call<Status>>()
|
||||
@ -61,7 +59,6 @@ class SendTootService : Service(), Injectable {
|
||||
|
||||
override fun onCreate() {
|
||||
AndroidInjection.inject(this)
|
||||
saveTootHelper = SaveTootHelper(database.tootDao(), this)
|
||||
super.onCreate()
|
||||
}
|
||||
|
||||
@ -284,54 +281,19 @@ class SendTootService : Service(), Injectable {
|
||||
|
||||
@JvmStatic
|
||||
fun sendTootIntent(context: Context,
|
||||
text: String,
|
||||
warningText: String,
|
||||
visibility: Status.Visibility,
|
||||
sensitive: Boolean,
|
||||
mediaIds: List<String>,
|
||||
mediaUris: List<Uri>,
|
||||
mediaDescriptions: List<String>,
|
||||
scheduledAt: String?,
|
||||
inReplyToId: String?,
|
||||
poll: NewPoll?,
|
||||
replyingStatusContent: String?,
|
||||
replyingStatusAuthorUsername: String?,
|
||||
savedJsonUrls: String?,
|
||||
account: AccountEntity,
|
||||
savedTootUid: Int
|
||||
tootToSend: TootToSend
|
||||
): Intent {
|
||||
val intent = Intent(context, SendTootService::class.java)
|
||||
|
||||
val idempotencyKey = randomAlphanumericString(16)
|
||||
|
||||
val tootToSend = TootToSend(text,
|
||||
warningText,
|
||||
visibility.serverString(),
|
||||
sensitive,
|
||||
mediaIds,
|
||||
mediaUris.map { it.toString() },
|
||||
mediaDescriptions,
|
||||
scheduledAt,
|
||||
inReplyToId,
|
||||
poll,
|
||||
replyingStatusContent,
|
||||
replyingStatusAuthorUsername,
|
||||
savedJsonUrls,
|
||||
account.id,
|
||||
savedTootUid,
|
||||
idempotencyKey,
|
||||
0)
|
||||
|
||||
intent.putExtra(KEY_TOOT, tootToSend)
|
||||
|
||||
if(mediaUris.isNotEmpty()) {
|
||||
if (tootToSend.mediaUris.isNotEmpty()) {
|
||||
// forward uri permissions
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
val uriClip = ClipData(
|
||||
ClipDescription("Toot Media", arrayOf("image/*", "video/*")),
|
||||
ClipData.Item(mediaUris[0])
|
||||
ClipData.Item(tootToSend.mediaUris[0])
|
||||
)
|
||||
mediaUris
|
||||
tootToSend.mediaUris
|
||||
.drop(1)
|
||||
.forEach { mediaUri ->
|
||||
uriClip.addItem(ClipData.Item(mediaUri))
|
||||
@ -348,7 +310,8 @@ class SendTootService : Service(), Injectable {
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class TootToSend(val text: String,
|
||||
data class TootToSend(
|
||||
val text: String,
|
||||
val warningText: String,
|
||||
val visibility: String,
|
||||
val sensitive: Boolean,
|
||||
@ -360,8 +323,9 @@ data class TootToSend(val text: String,
|
||||
val poll: NewPoll?,
|
||||
val replyingStatusContent: String?,
|
||||
val replyingStatusAuthorUsername: String?,
|
||||
val savedJsonUrls: String?,
|
||||
val savedJsonUrls: List<String>?,
|
||||
val accountId: Long,
|
||||
val savedTootUid: Int,
|
||||
val idempotencyKey: String,
|
||||
var retries: Int) : Parcelable
|
||||
var retries: Int
|
||||
) : Parcelable
|
||||
|
@ -0,0 +1,34 @@
|
||||
/* Copyright 2019 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.service
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
|
||||
interface ServiceClient {
|
||||
fun sendToot(tootToSend: TootToSend)
|
||||
}
|
||||
|
||||
class ServiceClientImpl(private val context: Context) : ServiceClient {
|
||||
override fun sendToot(tootToSend: TootToSend) {
|
||||
val intent = SendTootService.sendTootIntent(context, tootToSend)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
@ -18,7 +18,6 @@ package com.keylesspalace.tusky.service
|
||||
import android.annotation.TargetApi
|
||||
import android.content.Intent
|
||||
import android.service.quicksettings.TileService
|
||||
|
||||
import com.keylesspalace.tusky.MainActivity
|
||||
|
||||
/**
|
||||
|
@ -0,0 +1,93 @@
|
||||
/* Copyright 2019 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 androidx.lifecycle.*
|
||||
import io.reactivex.BackpressureStrategy
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.Single
|
||||
|
||||
inline fun <X, Y> LiveData<X>.map(crossinline mapFunction: (X) -> Y): LiveData<Y> =
|
||||
Transformations.map(this) { input -> mapFunction(input) }
|
||||
|
||||
inline fun <X, Y> LiveData<X>.switchMap(
|
||||
crossinline switchMapFunction: (X) -> LiveData<Y>
|
||||
): LiveData<Y> = Transformations.switchMap(this) { input -> switchMapFunction(input) }
|
||||
|
||||
inline fun <X> LiveData<X>.filter(crossinline predicate: (X) -> Boolean): LiveData<X> {
|
||||
val liveData = MediatorLiveData<X>()
|
||||
liveData.addSource(this) { value ->
|
||||
if (predicate(value)) {
|
||||
liveData.value = value
|
||||
}
|
||||
}
|
||||
return liveData
|
||||
}
|
||||
|
||||
fun LifecycleOwner.withLifecycleContext(body: LifecycleContext.() -> Unit) =
|
||||
LifecycleContext(this).apply(body)
|
||||
|
||||
class LifecycleContext(val lifecycleOwner: LifecycleOwner) {
|
||||
inline fun <T> LiveData<T>.observe(crossinline observer: (T) -> Unit) =
|
||||
this.observe(lifecycleOwner, Observer { observer(it) })
|
||||
|
||||
/**
|
||||
* Just hold a subscription,
|
||||
*/
|
||||
fun <T> LiveData<T>.subscribe() =
|
||||
this.observe(lifecycleOwner, Observer { })
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes @param [combiner] when value of both @param [a] and @param [b] are not null. Returns
|
||||
* [LiveData] with value set to the result of calling [combiner] with value of both.
|
||||
* Important! You still need to observe to the returned [LiveData] for [combiner] to be invoked.
|
||||
*/
|
||||
fun <A, B, R> combineLiveData(a: LiveData<A>, b: LiveData<B>, combiner: (A, B) -> R): LiveData<R> {
|
||||
val liveData = MediatorLiveData<R>()
|
||||
liveData.addSource(a) {
|
||||
if (a.value != null && b.value != null) {
|
||||
liveData.value = combiner(a.value!!, b.value!!)
|
||||
}
|
||||
}
|
||||
liveData.addSource(b) {
|
||||
if (a.value != null && b.value != null) {
|
||||
liveData.value = combiner(a.value!!, b.value!!)
|
||||
}
|
||||
}
|
||||
return liveData
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [LiveData] with value set to the result of calling [combiner] with value of [a] and [b]
|
||||
* after either changes. Doesn't check if either has value.
|
||||
* Important! You still need to observe to the returned [LiveData] for [combiner] to be invoked.
|
||||
*/
|
||||
fun <A, B, R> combineOptionalLiveData(a: LiveData<A>, b: LiveData<B>, combiner: (A?, B?) -> R): LiveData<R> {
|
||||
val liveData = MediatorLiveData<R>()
|
||||
liveData.addSource(a) {
|
||||
liveData.value = combiner(a.value, b.value)
|
||||
}
|
||||
liveData.addSource(b) {
|
||||
liveData.value = combiner(a.value, b.value)
|
||||
}
|
||||
return liveData
|
||||
}
|
||||
|
||||
fun <T> Single<T>.toLiveData() = LiveDataReactiveStreams.fromPublisher(this.toFlowable())
|
||||
fun <T> Observable<T>.toLiveData(
|
||||
backpressureStrategy: BackpressureStrategy = BackpressureStrategy.LATEST
|
||||
) = LiveDataReactiveStreams.fromPublisher(this.toFlowable(BackpressureStrategy.LATEST))
|
@ -5,16 +5,18 @@ import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.FileProvider;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.keylesspalace.tusky.BuildConfig;
|
||||
import com.keylesspalace.tusky.db.AppDatabase;
|
||||
import com.keylesspalace.tusky.db.TootDao;
|
||||
import com.keylesspalace.tusky.db.TootEntity;
|
||||
import com.keylesspalace.tusky.entity.NewPoll;
|
||||
@ -27,6 +29,8 @@ import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
public final class SaveTootHelper {
|
||||
|
||||
private static final String TAG = "SaveTootHelper";
|
||||
@ -35,15 +39,16 @@ public final class SaveTootHelper {
|
||||
private Context context;
|
||||
private Gson gson = new Gson();
|
||||
|
||||
public SaveTootHelper(@NonNull TootDao tootDao, @NonNull Context context) {
|
||||
this.tootDao = tootDao;
|
||||
@Inject
|
||||
public SaveTootHelper(@NonNull AppDatabase appDatabase, @NonNull Context context) {
|
||||
this.tootDao = appDatabase.tootDao();
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
public boolean saveToot(@NonNull String content,
|
||||
@NonNull String contentWarning,
|
||||
@Nullable String savedJsonUrls,
|
||||
@Nullable List<String> savedJsonUrls,
|
||||
@NonNull List<String> mediaUris,
|
||||
@NonNull List<String> mediaDescriptions,
|
||||
int savedTootUid,
|
||||
@ -58,31 +63,25 @@ public final class SaveTootHelper {
|
||||
}
|
||||
|
||||
// Get any existing file's URIs.
|
||||
ArrayList<String> existingUris = null;
|
||||
if (!TextUtils.isEmpty(savedJsonUrls)) {
|
||||
existingUris = gson.fromJson(savedJsonUrls,
|
||||
new TypeToken<ArrayList<String>>() {
|
||||
}.getType());
|
||||
}
|
||||
|
||||
String mediaUrlsSerialized = null;
|
||||
String mediaDescriptionsSerialized = null;
|
||||
|
||||
if (!ListUtils.isEmpty(mediaUris)) {
|
||||
List<String> savedList = saveMedia(mediaUris, existingUris);
|
||||
List<String> savedList = saveMedia(mediaUris, savedJsonUrls);
|
||||
if (!ListUtils.isEmpty(savedList)) {
|
||||
mediaUrlsSerialized = gson.toJson(savedList);
|
||||
if (!ListUtils.isEmpty(existingUris)) {
|
||||
deleteMedia(setDifference(existingUris, savedList));
|
||||
if (!ListUtils.isEmpty(savedJsonUrls)) {
|
||||
deleteMedia(setDifference(savedJsonUrls, savedList));
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
mediaDescriptionsSerialized = gson.toJson(mediaDescriptions);
|
||||
} else if (!ListUtils.isEmpty(existingUris)) {
|
||||
} else if (!ListUtils.isEmpty(savedJsonUrls)) {
|
||||
/* If there were URIs in the previous draft, but they've now been removed, those files
|
||||
* can be deleted. */
|
||||
deleteMedia(existingUris);
|
||||
deleteMedia(savedJsonUrls);
|
||||
}
|
||||
final TootEntity toot = new TootEntity(savedTootUid, content, mediaUrlsSerialized, mediaDescriptionsSerialized, contentWarning,
|
||||
inReplyToId,
|
||||
@ -111,7 +110,8 @@ public final class SaveTootHelper {
|
||||
public void deleteDraft(@NonNull TootEntity item) {
|
||||
// Delete any media files associated with the status.
|
||||
ArrayList<String> uris = gson.fromJson(item.getUrls(),
|
||||
new TypeToken<ArrayList<String>>() {}.getType());
|
||||
new TypeToken<ArrayList<String>>() {
|
||||
}.getType());
|
||||
if (uris != null) {
|
||||
for (String uriString : uris) {
|
||||
Uri uri = Uri.parse(uriString);
|
||||
|
@ -52,3 +52,12 @@ inline fun EditText.onTextChanged(
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
inline fun EditText.afterTextChanged(
|
||||
crossinline callback: (s: Editable) -> Unit) {
|
||||
addTextChangedListener(object : DefaultTextWatcher() {
|
||||
override fun afterTextChanged(s: Editable) {
|
||||
callback(s)
|
||||
}
|
||||
})
|
||||
}
|
@ -2,7 +2,7 @@
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/activity_compose"
|
||||
android:id="@+id/activityCompose"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
@ -30,10 +30,9 @@
|
||||
android:layout_gravity="end"
|
||||
android:padding="8dp"
|
||||
android:text="@string/at_symbol"
|
||||
android:textStyle="bold"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
android:textSize="?attr/status_text_large"
|
||||
/>
|
||||
android:textStyle="bold" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatButton
|
||||
android:id="@+id/hashButton"
|
||||
@ -43,10 +42,9 @@
|
||||
android:layout_gravity="end"
|
||||
android:padding="8dp"
|
||||
android:text="@string/hash_symbol"
|
||||
android:textStyle="bold"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
android:textSize="?attr/status_text_large"
|
||||
/>
|
||||
android:textStyle="bold" />
|
||||
</androidx.appcompat.widget.Toolbar>
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
@ -119,7 +117,7 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.keylesspalace.tusky.view.EditTextTyped
|
||||
<com.keylesspalace.tusky.components.compose.view.EditTextTyped
|
||||
android:id="@+id/composeEditField"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
@ -136,25 +134,19 @@
|
||||
android:textColorHint="?android:attr/textColorTertiary"
|
||||
android:textSize="?attr/status_text_large" />
|
||||
|
||||
<HorizontalScrollView
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/composeMediaPreviewBar"
|
||||
android:visibility="gone"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:scrollbars="none">
|
||||
android:scrollbars="none" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/compose_media_preview_bar"
|
||||
<com.keylesspalace.tusky.components.compose.view.PollPreviewView
|
||||
android:id="@+id/pollPreview"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp">
|
||||
|
||||
<!--This is filled at runtime with ImageView's for each preview in the upload queue.-->
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</HorizontalScrollView>
|
||||
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
@ -174,7 +166,7 @@
|
||||
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/action_photo_take"
|
||||
android:id="@+id/actionPhotoTake"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:drawablePadding="8dp"
|
||||
@ -183,7 +175,7 @@
|
||||
android:textSize="?attr/status_text_medium" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/action_photo_pick"
|
||||
android:id="@+id/actionPhotoPick"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:drawablePadding="8dp"
|
||||
@ -192,7 +184,7 @@
|
||||
android:textSize="?attr/status_text_medium" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/action_add_poll"
|
||||
android:id="@+id/addPollTextActionTextView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:drawablePadding="8dp"
|
||||
@ -217,7 +209,7 @@
|
||||
app:behavior_peekHeight="0dp"
|
||||
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" />
|
||||
|
||||
<com.keylesspalace.tusky.view.ComposeOptionsView
|
||||
<com.keylesspalace.tusky.components.compose.view.ComposeOptionsView
|
||||
android:id="@+id/composeOptionsBottomSheet"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
@ -231,7 +223,7 @@
|
||||
app:behavior_peekHeight="0dp"
|
||||
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" />
|
||||
|
||||
<com.keylesspalace.tusky.view.ComposeScheduleView
|
||||
<com.keylesspalace.tusky.components.compose.view.ComposeScheduleView
|
||||
android:id="@+id/composeScheduleView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
@ -337,7 +329,7 @@
|
||||
android:textSize="?attr/status_text_medium"
|
||||
tools:text="500" />
|
||||
|
||||
<com.keylesspalace.tusky.view.TootButton
|
||||
<com.keylesspalace.tusky.components.compose.view.TootButton
|
||||
android:id="@+id/composeTootButton"
|
||||
style="@style/TuskyButton"
|
||||
android:layout_width="@dimen/toot_button_width"
|
||||
|
@ -5,27 +5,28 @@
|
||||
|
||||
<Button
|
||||
android:id="@+id/resetScheduleButton"
|
||||
style="@style/TuskyButton.Outlined"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="@string/action_reset_schedule"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/scheduledDateTime"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingBottom="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:drawablePadding="4dp"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
android:drawablePadding="4dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="1"
|
||||
app:layout_constraintStart_toEndOf="@id/resetScheduleButton"
|
||||
tools:text="2020/01/01 00:00:00" />
|
||||
|
||||
</merge>
|
@ -487,7 +487,6 @@
|
||||
<string name="action_access_scheduled_toot">التبويقات المبَرمَجة</string>
|
||||
<string name="action_schedule_toot">برمجة تبويق</string>
|
||||
<string name="action_reset_schedule">صفّر</string>
|
||||
<string name="hint_configure_scheduled_toot">اضغط هنا لضبط برمجة التبويق.</string>
|
||||
<string name="post_lookup_error_format">خطأ أثناء البحث عن منشور %s</string>
|
||||
|
||||
<string name="title_bookmarks">الفواصل المرجعية</string>
|
||||
|
@ -508,7 +508,6 @@
|
||||
<string name="action_access_scheduled_toot">নির্ধারিত টুটগুলি</string>
|
||||
<string name="action_schedule_toot">নির্ধারিত টুট</string>
|
||||
<string name="action_reset_schedule">রিসেট</string>
|
||||
<string name="hint_configure_scheduled_toot">নির্ধারিত টুট কনফিগার করতে এখানে আলতো চাপুন।</string>
|
||||
<string name="about_powered_by_tusky">টাস্কি দ্বারা চালিত</string>
|
||||
<string name="post_lookup_error_format">%s পোস্ট অনুসন্ধানে ত্রুটি</string>
|
||||
|
||||
|
@ -526,7 +526,6 @@
|
||||
<string name="action_access_scheduled_toot">Toots programats</string>
|
||||
<string name="action_schedule_toot">Programar el toot</string>
|
||||
<string name="action_reset_schedule">Reiniciar</string>
|
||||
<string name="hint_configure_scheduled_toot">Clica aquí per configurar el toot programat.</string>
|
||||
<string name="about_powered_by_tusky">Desenvolupat per Tusky</string>
|
||||
<string name="description_status_bookmarked">Afegit a les adreces d\'interès</string>
|
||||
<string name="select_list_title">Seleccionar la llista</string>
|
||||
|
@ -470,7 +470,6 @@
|
||||
<string name="action_access_scheduled_toot">Plánované tooty</string>
|
||||
<string name="action_schedule_toot">Naplánovat toot</string>
|
||||
<string name="action_reset_schedule">Obnovit</string>
|
||||
<string name="hint_configure_scheduled_toot">Klepnutím sem nastavíte plánovaný toot.</string>
|
||||
<string name="pref_title_alway_open_spoiler">Vždy rozbalovat tooty označené varováními o obsahu</string>
|
||||
<string name="filter_dialog_whole_word">Celé slovo</string>
|
||||
<string name="filter_dialog_whole_word_description">Je-li klíčové slovo nebo fráze pouze alfanumerická, bude použita pouze, pokud odpovídá celému slovu</string>
|
||||
|
@ -451,6 +451,5 @@
|
||||
<string name="action_access_scheduled_toot">Geplante Beiträge</string>
|
||||
<string name="action_schedule_toot">Plane Beitrag</string>
|
||||
<string name="action_reset_schedule">Zurücksetzen</string>
|
||||
<string name="hint_configure_scheduled_toot">Drücke hier, um den geplanten Beitrag zu konfigurieren.</string>
|
||||
<string name="abbreviated_in_years">Dies sind Zeitstempel für Status. Beispiele: \"16s\" oder \"2t\".</string>
|
||||
</resources>
|
||||
|
@ -464,7 +464,6 @@
|
||||
<string name="action_access_scheduled_toot">Planitaj mesaĝoj</string>
|
||||
<string name="action_schedule_toot">Plani mesaĝon</string>
|
||||
<string name="action_reset_schedule">Restarigi</string>
|
||||
<string name="hint_configure_scheduled_toot">Frapetu ĉi-tie por agordi la planitan mesaĝon.</string>
|
||||
<string name="about_powered_by_tusky">Funkciigita de Tusky</string>
|
||||
<string name="description_status_bookmarked">Aldonita al la legosignoj</string>
|
||||
<string name="select_list_title">Elekti la liston</string>
|
||||
|
@ -465,7 +465,6 @@
|
||||
<string name="action_access_scheduled_toot">Estados programados</string>
|
||||
<string name="action_schedule_toot">Programar estado</string>
|
||||
<string name="action_reset_schedule">Reiniciar</string>
|
||||
<string name="hint_configure_scheduled_toot">Pulsa aquí para configurar un estado programado.</string>
|
||||
<string name="post_lookup_error_format">Error al buscar el post %s</string>
|
||||
|
||||
<string name="about_powered_by_tusky">Potenciado por Tusky</string>
|
||||
|
@ -311,7 +311,6 @@
|
||||
|
||||
<string name="confirmation_domain_unmuted">%s ez dago ezkutatua</string>
|
||||
|
||||
<string name="hint_configure_scheduled_toot">Sakatu hemen programatutako tuta konfiguratzeko.</string>
|
||||
<string name="dialog_redraft_toot_warning">Tut hau ezabatu eta zirriborro berria egin\?</string>
|
||||
<string name="mute_domain_warning">Ziur al zaude %s ezabatu nahi duzula\? Domeinu horretatik datorren edukia ez duzu denbora-lerro publikoetan edo jakinarazpenentan ikusiko. Domeinu horretan dituzun jarraitzaileak ezabatuko dira.</string>
|
||||
<string name="mute_domain_warning_dialog_ok">Domeinu osoa ezkutatu</string>
|
||||
|
@ -460,7 +460,6 @@
|
||||
<string name="action_access_scheduled_toot">بوقهای زمانبندیشده</string>
|
||||
<string name="action_schedule_toot">زمانبندی بوق</string>
|
||||
<string name="action_reset_schedule">بازنشانی</string>
|
||||
<string name="hint_configure_scheduled_toot">برای پیکربندی بوق زمانبندیشده، اینجا را بزنید.</string>
|
||||
<string name="mute_domain_warning">مطمئنید میخواهید تمام %s را مسدود کنید؟ محتوای آن دامنه را در هیچیک از خط زمانیها یا در آگاهیهایتان نخواهید دید. پیروانتان از آن دامنه، برداشته خواهند شد.</string>
|
||||
<string name="filter_dialog_whole_word_description">هنگامی که کلیدواژه یا عبارت، فقط حروفعددی باشد، فقط اگر با تمام واژه مطابق باشد، اعمال خواهد شد</string>
|
||||
<string name="filter_add_description">عبارت پالایش</string>
|
||||
|
@ -472,7 +472,6 @@
|
||||
<string name="action_access_scheduled_toot">Pouets planifiés</string>
|
||||
<string name="action_schedule_toot">Planifier le pouet</string>
|
||||
<string name="action_reset_schedule">Réinitialiser</string>
|
||||
<string name="hint_configure_scheduled_toot">Appuyez ici pour configurer le pouet planifié.</string>
|
||||
<string name="post_lookup_error_format">Erreur lors de la récupération du message %s</string>
|
||||
|
||||
<string name="about_powered_by_tusky">Propulsé par Tusky</string>
|
||||
|
@ -470,7 +470,6 @@
|
||||
<string name="action_access_scheduled_toot">Időzített tülkök</string>
|
||||
<string name="action_schedule_toot">Tülk Időzítése</string>
|
||||
<string name="action_reset_schedule">Visszaállítás</string>
|
||||
<string name="hint_configure_scheduled_toot">Ide nyúlj az időzített tülkök beállításához.</string>
|
||||
<string name="post_lookup_error_format">Nem találjuk ezt a posztot %s</string>
|
||||
|
||||
<string name="title_bookmarks">Könyvjelzők</string>
|
||||
|
@ -476,6 +476,5 @@
|
||||
<string name="action_access_scheduled_toot">Toot programmati</string>
|
||||
<string name="action_schedule_toot">Programma un toot</string>
|
||||
<string name="action_reset_schedule">RIpristina</string>
|
||||
<string name="hint_configure_scheduled_toot">Tocca qui configurare i toot programmati.</string>
|
||||
<string name="poll_info_format"> <!-- 15 votes • 1 hour left --> %1$s • %2$s</string>
|
||||
</resources>
|
||||
|
@ -81,7 +81,6 @@
|
||||
</style>
|
||||
|
||||
<style name="TuskyImageButton.Dark" parent="@style/Widget.MaterialComponents.Button.UnelevatedButton">
|
||||
<item name="android:tint">@color/text_color_tertiary_dark</item>
|
||||
<item name="android:background">?attr/selectableItemBackgroundBorderless</item>
|
||||
</style>
|
||||
|
||||
|
@ -507,7 +507,6 @@
|
||||
<string name="action_access_scheduled_toot">Planlagte toots</string>
|
||||
<string name="action_schedule_toot">Planlegg toot</string>
|
||||
<string name="action_reset_schedule">Tilbakestill</string>
|
||||
<string name="hint_configure_scheduled_toot">Klikk her for å konfigurere planlagt toot.</string>
|
||||
<string name="post_lookup_error_format">Det oppsto en feil under henting av %s</string>
|
||||
|
||||
<string name="about_powered_by_tusky">Drevet av Tusky</string>
|
||||
|
@ -477,7 +477,6 @@
|
||||
<string name="action_access_scheduled_toot">Tuts planificats</string>
|
||||
<string name="action_schedule_toot">Planificar de tuts</string>
|
||||
<string name="action_reset_schedule">Escafar</string>
|
||||
<string name="hint_configure_scheduled_toot">Tocatz aquí per configurar los tuts planificats.</string>
|
||||
<string name="post_lookup_error_format">Error en cercant la publicacion %s</string>
|
||||
|
||||
<string name="about_powered_by_tusky">Propulsat per Tusky</string>
|
||||
|
@ -484,7 +484,6 @@
|
||||
<string name="action_access_scheduled_toot">Zaplanowane wpisy</string>
|
||||
<string name="action_schedule_toot">Zaplanuj wpis</string>
|
||||
<string name="action_reset_schedule">Resetuj</string>
|
||||
<string name="hint_configure_scheduled_toot">Dotknij tutaj, żeby skonfigurować zaplanowany wpis.</string>
|
||||
<string name="about_powered_by_tusky">Napędzane przez Tusky</string>
|
||||
<string name="post_lookup_error_format">Błąd przy wyszukiwaniu wpisu %s</string>
|
||||
|
||||
|
@ -472,7 +472,6 @@
|
||||
<string name="action_access_scheduled_toot">Agendados</string>
|
||||
<string name="action_schedule_toot">Agendar toot</string>
|
||||
<string name="action_reset_schedule">Cancelar</string>
|
||||
<string name="hint_configure_scheduled_toot">Toque aqui para agendar</string>
|
||||
<string name="post_lookup_error_format">Erro ao pesquisar %s</string>
|
||||
|
||||
<string name="title_bookmarks">Salvos</string>
|
||||
|
@ -540,7 +540,6 @@
|
||||
<string name="action_access_scheduled_toot">Отложенные записи</string>
|
||||
<string name="action_schedule_toot">Отложить запись</string>
|
||||
<string name="action_reset_schedule">Сброс</string>
|
||||
<string name="hint_configure_scheduled_toot">Нажмите для выбора времени отправки.</string>
|
||||
<string name="post_lookup_error_format">Ошибка при поиске сообщения / ний</string>
|
||||
|
||||
<string name="title_bookmarks">Закладки</string>
|
||||
|
@ -521,7 +521,6 @@
|
||||
<string name="action_access_scheduled_toot">Napovedani tuti</string>
|
||||
<string name="action_reset_schedule">Ponastavi</string>
|
||||
<string name="action_schedule_toot">Napovej tut</string>
|
||||
<string name="hint_configure_scheduled_toot">Dotaknite se tukaj, da nastavite napovedan tut.</string>
|
||||
<string name="post_lookup_error_format">Napaka pri iskanju objave %s</string>
|
||||
|
||||
<string name="about_powered_by_tusky">Poganja ga Tusky</string>
|
||||
|
@ -468,7 +468,6 @@
|
||||
<string name="action_access_scheduled_toot">Schemalagda toots</string>
|
||||
<string name="action_schedule_toot">Schemalägg toot</string>
|
||||
<string name="action_reset_schedule">Återställ</string>
|
||||
<string name="hint_configure_scheduled_toot">Knacka här för att konfigurera schemalagd toot.</string>
|
||||
<string name="post_lookup_error_format">Fel vid uppslagning av status %s</string>
|
||||
|
||||
</resources>
|
||||
|
@ -435,7 +435,6 @@
|
||||
<string name="action_access_scheduled_toot">Zamanlanmış iletiler</string>
|
||||
<string name="action_schedule_toot">İleti zamanla</string>
|
||||
<string name="action_reset_schedule">Sıfırla</string>
|
||||
<string name="hint_configure_scheduled_toot">Zamanlanmış iletiyi yapılandırmak için buraya dokunun.</string>
|
||||
<string name="dialog_redraft_toot_warning">Bu iletiyi silip yeniden düzenlemek istiyor musun\?</string>
|
||||
<string name="pref_title_bot_overlay">Botlar için gösterge göster</string>
|
||||
<string name="about_powered_by_tusky">Tusky tarafından desteklenmektedir</string>
|
||||
|
@ -159,7 +159,6 @@
|
||||
|
||||
<string name="hint_domain">Which instance?</string>
|
||||
<string name="hint_compose">What\'s happening?</string>
|
||||
<string name="hint_configure_scheduled_toot">Tap here to configure scheduled toot.</string>
|
||||
<string name="hint_content_warning">Content warning</string>
|
||||
<string name="hint_display_name">Display name</string>
|
||||
<string name="hint_note">Bio</string>
|
||||
|
@ -144,7 +144,6 @@
|
||||
</style>
|
||||
|
||||
<style name="TuskyImageButton.Light" parent="@style/Widget.MaterialComponents.Button.UnelevatedButton">
|
||||
<item name="android:tint">@color/text_color_tertiary_light</item>
|
||||
<item name="android:background">?attr/selectableItemBackgroundBorderless</item>
|
||||
</style>
|
||||
|
||||
|
@ -18,16 +18,21 @@ package com.keylesspalace.tusky
|
||||
|
||||
import android.text.SpannedString
|
||||
import android.widget.EditText
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.InstanceDao
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeViewModel
|
||||
import com.keylesspalace.tusky.components.compose.DEFAULT_CHARACTER_LIMIT
|
||||
import com.keylesspalace.tusky.components.compose.MediaUploader
|
||||
import com.keylesspalace.tusky.db.*
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.Instance
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import okhttp3.Request
|
||||
import org.junit.Assert
|
||||
import com.keylesspalace.tusky.service.ServiceClient
|
||||
import com.keylesspalace.tusky.util.SaveTootHelper
|
||||
import com.nhaarman.mockitokotlin2.any
|
||||
import io.reactivex.Single
|
||||
import io.reactivex.SingleObserver
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@ -35,15 +40,8 @@ import org.junit.runner.RunWith
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.mockito.Mockito.mock
|
||||
import org.robolectric.Robolectric
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.reactivex.Single
|
||||
import io.reactivex.SingleObserver
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.fakes.RoboMenuItem
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
|
||||
|
||||
/**
|
||||
* Created by charlag on 3/7/18.
|
||||
@ -52,14 +50,15 @@ import retrofit2.Response
|
||||
@Config(application = FakeTuskyApplication::class, sdk = [28])
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ComposeActivityTest {
|
||||
|
||||
private lateinit var activity: ComposeActivity
|
||||
private lateinit var accountManagerMock: AccountManager
|
||||
private lateinit var apiMock: MastodonApi
|
||||
|
||||
private val instanceDomain = "example.domain"
|
||||
|
||||
private val account = AccountEntity(
|
||||
id = 1,
|
||||
domain = "example.token",
|
||||
domain = instanceDomain,
|
||||
accessToken = "token",
|
||||
isActive = true,
|
||||
accountId = "1",
|
||||
@ -83,30 +82,10 @@ class ComposeActivityTest {
|
||||
activity = controller.get()
|
||||
|
||||
accountManagerMock = mock(AccountManager::class.java)
|
||||
`when`(accountManagerMock.activeAccount).thenReturn(account)
|
||||
|
||||
apiMock = mock(MastodonApi::class.java)
|
||||
`when`(apiMock.getCustomEmojis()).thenReturn(object: Call<List<Emoji>> {
|
||||
override fun isExecuted(): Boolean {
|
||||
return false
|
||||
}
|
||||
override fun clone(): Call<List<Emoji>> {
|
||||
throw Error("not implemented")
|
||||
}
|
||||
override fun isCanceled(): Boolean {
|
||||
throw Error("not implemented")
|
||||
}
|
||||
override fun cancel() {
|
||||
throw Error("not implemented")
|
||||
}
|
||||
override fun execute(): Response<List<Emoji>> {
|
||||
throw Error("not implemented")
|
||||
}
|
||||
override fun request(): Request {
|
||||
throw Error("not implemented")
|
||||
}
|
||||
|
||||
override fun enqueue(callback: Callback<List<Emoji>>?) {}
|
||||
})
|
||||
`when`(apiMock.getCustomEmojis()).thenReturn(Single.just(emptyList()))
|
||||
`when`(apiMock.getInstance()).thenReturn(object: Single<Instance>() {
|
||||
override fun subscribeActual(observer: SingleObserver<in Instance>) {
|
||||
val instance = instanceResponseCallback?.invoke()
|
||||
@ -119,15 +98,27 @@ class ComposeActivityTest {
|
||||
})
|
||||
|
||||
val instanceDaoMock = mock(InstanceDao::class.java)
|
||||
`when`(instanceDaoMock.loadMetadataForInstance(any())).thenReturn(
|
||||
Single.just(InstanceEntity(instanceDomain, emptyList(),null, null, null, null))
|
||||
)
|
||||
|
||||
val dbMock = mock(AppDatabase::class.java)
|
||||
`when`(dbMock.instanceDao()).thenReturn(instanceDaoMock)
|
||||
|
||||
activity.mastodonApi = apiMock
|
||||
val viewModel = ComposeViewModel(
|
||||
apiMock,
|
||||
accountManagerMock,
|
||||
mock(MediaUploader::class.java),
|
||||
mock(ServiceClient::class.java),
|
||||
mock(SaveTootHelper::class.java),
|
||||
dbMock
|
||||
)
|
||||
|
||||
val viewModelFactoryMock = mock(ViewModelFactory::class.java)
|
||||
`when`(viewModelFactoryMock.create(ComposeViewModel::class.java)).thenReturn(viewModel)
|
||||
|
||||
activity.accountManager = accountManagerMock
|
||||
activity.database = dbMock
|
||||
|
||||
`when`(accountManagerMock.activeAccount).thenReturn(account)
|
||||
|
||||
activity.viewModelFactory = viewModelFactoryMock
|
||||
|
||||
controller.create().start()
|
||||
}
|
||||
@ -164,7 +155,7 @@ class ComposeActivityTest {
|
||||
fun whenMaximumTootCharsIsNull_defaultLimitIsUsed() {
|
||||
instanceResponseCallback = { getInstanceWithMaximumTootCharacters(null) }
|
||||
setupActivity()
|
||||
assertEquals(ComposeActivity.STATUS_CHARACTER_LIMIT, activity.maximumTootCharacters)
|
||||
assertEquals(DEFAULT_CHARACTER_LIMIT, activity.maximumTootCharacters)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -196,7 +187,7 @@ class ComposeActivityTest {
|
||||
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:"
|
||||
val additionalContent = " Check out this @image #search result: "
|
||||
insertSomeTextInContent(shortUrl + additionalContent + url)
|
||||
Assert.assertEquals(activity.calculateTextLength(), additionalContent.length + shortUrl.length + ComposeActivity.MAXIMUM_URL_LENGTH)
|
||||
assertEquals(activity.calculateTextLength(), additionalContent.length + shortUrl.length + ComposeActivity.MAXIMUM_URL_LENGTH)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -204,7 +195,7 @@ class ComposeActivityTest {
|
||||
val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:"
|
||||
val additionalContent = " Check out this @image #search result: "
|
||||
insertSomeTextInContent(url + additionalContent + url)
|
||||
Assert.assertEquals(activity.calculateTextLength(), additionalContent.length + (ComposeActivity.MAXIMUM_URL_LENGTH * 2))
|
||||
assertEquals(activity.calculateTextLength(), additionalContent.length + (ComposeActivity.MAXIMUM_URL_LENGTH * 2))
|
||||
}
|
||||
|
||||
private fun clickUp() {
|
||||
@ -256,13 +247,5 @@ class ComposeActivityTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun getSuccessResponseCallbackWithMaximumTootCharacters(maximumTootCharacters: Int?): (Call<Instance>?, Callback<Instance>?) -> Unit
|
||||
{
|
||||
return {
|
||||
call: Call<Instance>?, callback: Callback<Instance>? ->
|
||||
if (call != null) {
|
||||
callback?.onResponse(call, Response.success(getInstanceWithMaximumTootCharacters(maximumTootCharacters)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user