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