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