mirror of https://github.com/tuskyapp/Tusky.git
Browse Source
* 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.zippull/1594/head
68 changed files with 3153 additions and 2657 deletions
@ -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')" |
||||||
|
] |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -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 |
||||||
|
) |
||||||
@ -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 |
||||||
|
|
||||||
|
} |
||||||
|
} |
||||||
@ -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() |
||||||
|
} |
||||||
@ -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) |
||||||
|
} |
||||||
@ -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) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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)) |
||||||
Loading…
Reference in new issue