diff --git a/app/build.gradle b/app/build.gradle index f4a72bda3..ba4434e9c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -99,11 +99,12 @@ project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { } } -ext.roomVersion = '2.2.1' +ext.lifecycleVersion = "2.1.0" +ext.roomVersion = '2.2.3' ext.retrofitVersion = '2.6.0' ext.okhttpVersion = '4.2.2' ext.glideVersion = '4.10.0' -ext.daggerVersion = '2.25.2' +ext.daggerVersion = '2.25.3' repositories { maven { @@ -116,26 +117,28 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - implementation "androidx.core:core-ktx:1.2.0-beta01" + implementation "androidx.core:core-ktx:1.2.0-rc01" implementation "androidx.appcompat:appcompat:1.1.0" implementation "androidx.fragment:fragment-ktx:1.1.0" - implementation "androidx.browser:browser:1.0.0" - implementation "androidx.recyclerview:recyclerview:1.0.0" - implementation "androidx.exifinterface:exifinterface:1.0.0" + implementation "androidx.browser:browser:1.2.0" + implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0" + implementation "androidx.recyclerview:recyclerview:1.1.0" + implementation "androidx.exifinterface:exifinterface:1.1.0" implementation "androidx.cardview:cardview:1.0.0" implementation "androidx.preference:preference:1.1.0" - implementation "androidx.sharetarget:sharetarget:1.0.0-beta01" + implementation "androidx.sharetarget:sharetarget:1.0.0-rc01" implementation "androidx.emoji:emoji:1.0.0" implementation "androidx.emoji:emoji-appcompat:1.0.0" - implementation "androidx.lifecycle:lifecycle-extensions:2.1.0" + implementation "androidx.lifecycle:lifecycle-extensions:$lifecycleVersion" + implementation "androidx.lifecycle:lifecycle-reactivestreams:$lifecycleVersion" implementation "androidx.constraintlayout:constraintlayout:1.1.3" - implementation "androidx.paging:paging-runtime-ktx:2.1.0" - implementation "androidx.viewpager2:viewpager2:1.0.0-rc01" + implementation "androidx.paging:paging-runtime-ktx:2.1.1" + implementation "androidx.viewpager2:viewpager2:1.0.0" implementation "androidx.room:room-runtime:$roomVersion" implementation "androidx.room:room-rxjava2:$roomVersion" kapt "androidx.room:room-compiler:$roomVersion" - implementation "com.google.android.material:material:1.1.0-beta01" + implementation "com.google.android.material:material:1.1.0-rc01" implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" @@ -149,7 +152,7 @@ dependencies { implementation "com.github.bumptech.glide:glide:$glideVersion" implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion" - implementation "io.reactivex.rxjava2:rxjava:2.2.13" + implementation "io.reactivex.rxjava2:rxjava:2.2.16" implementation "io.reactivex.rxjava2:rxandroid:2.1.1" implementation "io.reactivex.rxjava2:rxkotlin:2.4.0" @@ -162,7 +165,7 @@ dependencies { implementation "com.google.dagger:dagger-android-support:$daggerVersion" kapt "com.google.dagger:dagger-android-processor:$daggerVersion" - implementation "com.github.connyduck:sparkbutton:2.0.1" + implementation "com.github.connyduck:sparkbutton:3.0.0" implementation "com.github.chrisbanes:PhotoView:2.3.0" @@ -182,7 +185,7 @@ dependencies { testImplementation "androidx.test.ext:junit:1.1.1" testImplementation "org.robolectric:robolectric:4.3.1" - testImplementation "org.mockito:mockito-inline:3.1.0" + testImplementation "org.mockito:mockito-inline:3.2.4" testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0" androidTestImplementation("androidx.test.espresso:espresso-core:3.1.1", { diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/21.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/21.json new file mode 100644 index 000000000..7845dade1 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/21.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/green/res/mipmap-hdpi/ic_shortcut_compose.png b/app/src/green/res/mipmap-hdpi/ic_shortcut_compose.png deleted file mode 100644 index 422323b27..000000000 Binary files a/app/src/green/res/mipmap-hdpi/ic_shortcut_compose.png and /dev/null differ diff --git a/app/src/green/res/mipmap-mdpi/ic_shortcut_compose.png b/app/src/green/res/mipmap-mdpi/ic_shortcut_compose.png deleted file mode 100644 index 1684f2884..000000000 Binary files a/app/src/green/res/mipmap-mdpi/ic_shortcut_compose.png and /dev/null differ diff --git a/app/src/green/res/mipmap-xhdpi/ic_shortcut_compose.png b/app/src/green/res/mipmap-xhdpi/ic_shortcut_compose.png deleted file mode 100644 index eb226e091..000000000 Binary files a/app/src/green/res/mipmap-xhdpi/ic_shortcut_compose.png and /dev/null differ diff --git a/app/src/green/res/mipmap-xxhdpi/ic_shortcut_compose.png b/app/src/green/res/mipmap-xxhdpi/ic_shortcut_compose.png deleted file mode 100644 index 3e333b9d0..000000000 Binary files a/app/src/green/res/mipmap-xxhdpi/ic_shortcut_compose.png and /dev/null differ diff --git a/app/src/green/res/mipmap-xxxhdpi/ic_shortcut_compose.png b/app/src/green/res/mipmap-xxxhdpi/ic_shortcut_compose.png deleted file mode 100644 index 415db1849..000000000 Binary files a/app/src/green/res/mipmap-xxxhdpi/ic_shortcut_compose.png and /dev/null differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1e15eaa69..d4ca7da63 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -96,7 +96,7 @@ >> { + accountFieldAdapter.fields = it + accountFieldAdapter.notifyDataSetChanged() + + }) } /** @@ -377,7 +384,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI val emojifiedNote = CustomEmojiHelper.emojifyText(account.note, account.emojis, accountNoteTextView) LinkHelper.setClickableText(accountNoteTextView, emojifiedNote, null, this, false) - accountFieldAdapter.fields = account.fields ?: emptyList() + // accountFieldAdapter.fields = account.fields ?: emptyList() accountFieldAdapter.emojis = account.emojis ?: emptyList() accountFieldAdapter.notifyDataSetChanged() @@ -471,7 +478,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI // this is necessary because API 19 can't handle vector compound drawables val movedIcon = ContextCompat.getDrawable(this, R.drawable.ic_briefcase)?.mutate() val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary) - movedIcon?.setColorFilter(textColor, PorterDuff.Mode.SRC_IN) + movedIcon?.colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN) accountMovedText.setCompoundDrawablesRelativeWithIntrinsicBounds(movedIcon, null, null, null) } @@ -693,9 +700,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI private fun mention() { loadedAccount?.let { - val intent = ComposeActivity.IntentBuilder() - .mentionedUsernames(setOf(it.username)) - .build(this) + val intent = ComposeActivity.startIntent(this, + ComposeActivity.ComposeOptions(mentionedUsernames = setOf(it.username))) startActivity(intent) } } @@ -754,7 +760,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI return true } R.id.action_report -> { - if(loadedAccount != null) { + if (loadedAccount != null) { startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount!!.username)) } return true diff --git a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java deleted file mode 100644 index d0cc293eb..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java +++ /dev/null @@ -1,2413 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky; - -import android.Manifest; -import android.annotation.SuppressLint; -import android.app.DatePickerDialog; -import android.app.ProgressDialog; -import android.app.TimePickerDialog; -import android.content.ContentResolver; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.content.res.AssetFileDescriptor; -import android.content.res.Resources; -import android.content.res.TypedArray; -import android.graphics.Bitmap; -import android.graphics.PorterDuff; -import android.graphics.drawable.Drawable; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; -import android.net.Uri; -import android.os.AsyncTask; -import android.os.Build; -import android.os.Bundle; -import android.os.Environment; -import android.os.Parcel; -import android.os.Parcelable; -import android.preference.PreferenceManager; -import android.provider.MediaStore; -import android.text.Editable; -import android.text.InputFilter; -import android.text.InputType; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.text.style.URLSpan; -import android.util.DisplayMetrics; -import android.util.Log; -import android.view.KeyEvent; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.view.Window; -import android.view.WindowManager; -import android.webkit.MimeTypeMap; -import android.widget.Button; -import android.widget.CheckBox; -import android.widget.DatePicker; -import android.widget.EditText; -import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.PopupMenu; -import android.widget.TextView; -import android.widget.TimePicker; -import android.widget.Toast; - -import androidx.annotation.ColorInt; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.Px; -import androidx.annotation.StringRes; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.content.res.AppCompatResources; -import androidx.appcompat.widget.Toolbar; -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.lifecycle.Lifecycle; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.transition.TransitionManager; - -import com.bumptech.glide.Glide; -import com.google.android.material.bottomsheet.BottomSheetBehavior; -import com.google.android.material.snackbar.Snackbar; -import com.google.gson.Gson; -import com.google.gson.reflect.TypeToken; -import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter; -import com.keylesspalace.tusky.adapter.EmojiAdapter; -import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener; -import com.keylesspalace.tusky.appstore.EventHub; -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent; -import com.keylesspalace.tusky.db.AccountEntity; -import com.keylesspalace.tusky.db.AppDatabase; -import com.keylesspalace.tusky.db.InstanceEntity; -import com.keylesspalace.tusky.di.Injectable; -import com.keylesspalace.tusky.entity.Account; -import com.keylesspalace.tusky.entity.Attachment; -import com.keylesspalace.tusky.entity.Emoji; -import com.keylesspalace.tusky.entity.Instance; -import com.keylesspalace.tusky.entity.NewPoll; -import com.keylesspalace.tusky.entity.SearchResult; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.network.MastodonApi; -import com.keylesspalace.tusky.network.ProgressRequestBody; -import com.keylesspalace.tusky.service.SendTootService; -import com.keylesspalace.tusky.util.ComposeTokenizer; -import com.keylesspalace.tusky.util.CountUpDownLatch; -import com.keylesspalace.tusky.util.DownsizeImageTask; -import com.keylesspalace.tusky.util.IOUtils; -import com.keylesspalace.tusky.util.ImageLoadingHelper; -import com.keylesspalace.tusky.util.ListUtils; -import com.keylesspalace.tusky.util.SaveTootHelper; -import com.keylesspalace.tusky.util.SpanUtilsKt; -import com.keylesspalace.tusky.util.StringUtils; -import com.keylesspalace.tusky.util.ThemeUtils; -import com.keylesspalace.tusky.util.VersionUtils; -import com.keylesspalace.tusky.view.AddPollDialog; -import com.keylesspalace.tusky.view.ComposeOptionsListener; -import com.keylesspalace.tusky.view.ComposeOptionsView; -import com.keylesspalace.tusky.view.ComposeScheduleView; -import com.keylesspalace.tusky.view.EditTextTyped; -import com.keylesspalace.tusky.view.PollPreviewView; -import com.keylesspalace.tusky.view.ProgressImageView; -import com.keylesspalace.tusky.view.TootButton; -import com.mikepenz.google_material_typeface_library.GoogleMaterial; -import com.mikepenz.iconics.IconicsDrawable; - -import org.jetbrains.annotations.NotNull; - -import java.io.File; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Date; -import java.util.Iterator; -import java.util.List; -import java.util.Locale; -import java.util.concurrent.CountDownLatch; - -import javax.inject.Inject; - -import at.connyduck.sparkbutton.helpers.Utils; -import io.reactivex.Single; -import io.reactivex.SingleObserver; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.Disposable; -import io.reactivex.schedulers.Schedulers; -import kotlin.collections.CollectionsKt; -import okhttp3.MediaType; -import okhttp3.MultipartBody; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; - -import static com.keylesspalace.tusky.util.MediaUtilsKt.MEDIA_SIZE_UNKNOWN; -import static com.keylesspalace.tusky.util.MediaUtilsKt.getImageSquarePixels; -import static com.keylesspalace.tusky.util.MediaUtilsKt.getImageThumbnail; -import static com.keylesspalace.tusky.util.MediaUtilsKt.getMediaSize; -import static com.keylesspalace.tusky.util.MediaUtilsKt.getSampledBitmap; -import static com.keylesspalace.tusky.util.MediaUtilsKt.getVideoThumbnail; -import static com.uber.autodispose.AutoDispose.autoDisposable; -import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from; - -public final class ComposeActivity - extends BaseActivity - implements ComposeOptionsListener, - ComposeAutoCompleteAdapter.AutocompletionProvider, - OnEmojiSelectedListener, - Injectable, InputConnectionCompat.OnCommitContentListener, - TimePickerDialog.OnTimeSetListener { - - private static final String TAG = "ComposeActivity"; // logging tag - static final int STATUS_CHARACTER_LIMIT = 500; - private static final int STATUS_IMAGE_SIZE_LIMIT = 8388608; // 8MiB - private static final int STATUS_VIDEO_SIZE_LIMIT = 41943040; // 40MiB - private static final int STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216; // 4096^2 Pixels - private static final int MEDIA_PICK_RESULT = 1; - private static final int MEDIA_TAKE_PHOTO_RESULT = 2; - private static final int PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1; - - private static final String SAVED_TOOT_UID_EXTRA = "saved_toot_uid"; - private static final String TOOT_TEXT_EXTRA = "toot_text"; - private static final String SAVED_JSON_URLS_EXTRA = "saved_json_urls"; - private static final String SAVED_JSON_DESCRIPTIONS_EXTRA = "saved_json_descriptions"; - private static final String TOOT_VISIBILITY_EXTRA = "toot_visibility"; - private static final String IN_REPLY_TO_ID_EXTRA = "in_reply_to_id"; - private static final String QUOTE_ID_EXTRA = "quote_id"; - private static final String QUOTE_URL_EXTRA = "quote_url"; - private static final String REPLY_VISIBILITY_EXTRA = "reply_visibility"; - private static final String CONTENT_WARNING_EXTRA = "content_warning"; - private static final String MENTIONED_USERNAMES_EXTRA = "mentioned_usernames"; - private static final String REPLYING_STATUS_AUTHOR_USERNAME_EXTRA = "replying_author_nickname_extra"; - private static final String REPLYING_STATUS_CONTENT_EXTRA = "replying_status_content"; - private static final String MEDIA_ATTACHMENTS_EXTRA = "media_attachments"; - private static final String SCHEDULED_AT_EXTRA = "scheduled_at"; - private static final String SENSITIVE_EXTRA = "sensitive"; - private static final String POLL_EXTRA = "poll"; - private static final String TOOT_RIGHT_NOW = "toot_right_now"; - // Mastodon only counts URLs as this long in terms of status character limits - static final int MAXIMUM_URL_LENGTH = 23; - // https://github.com/tootsuite/mastodon/blob/1656663/app/models/media_attachment.rb#L94 - private static final int MEDIA_DESCRIPTION_CHARACTER_LIMIT = 420; - - public static final String[] CAN_USE_UNLEAKABLE = {"itabashi.0j0.jp", "n-sr.org", "odakyu.app"}; - private static final String[] CAN_USE_QUOTE_ID = {"odakyu.app", "biwakodon.com", "dtp-mstdn.jp", "nitiasa.com", "comm.cx", "fedibird.com"}; - - public static final String PREF_DEFAULT_TAG = "default_tag"; - public static final String PREF_USE_DEFAULT_TAG = "use_default_tag"; - - @Inject - public MastodonApi mastodonApi; - @Inject - public AppDatabase database; - @Inject - public EventHub eventHub; - - private TextView replyTextView; - private TextView replyContentTextView; - private EditTextTyped textEditor; - private LinearLayout mediaPreviewBar; - private View contentWarningBar; - private EditText contentWarningEditor; - private CheckBox useDefaultTag; - private EditText defaultTagEditText; - private TextView charactersLeft; - private TootButton tootButton; - private ImageButton pickButton; - private ImageButton visibilityButton; - private ImageButton contentWarningButton; - private ImageButton emojiButton; - private ImageButton hideMediaToggle; - private ImageButton scheduleButton; - private TextView actionAddPoll; - private Button atButton; - private Button hashButton; - - private ComposeOptionsView composeOptionsView; - private BottomSheetBehavior composeOptionsBehavior; - private BottomSheetBehavior addMediaBehavior; - private BottomSheetBehavior emojiBehavior; - private BottomSheetBehavior scheduleBehavior; - private ComposeScheduleView scheduleView; - private RecyclerView emojiView; - - private PollPreviewView pollPreview; - - // this only exists when a status is trying to be sent, but uploads are still occurring - private ProgressDialog finishingUploadDialog; - private String inReplyToId; - private String quoteId; - private String quoteUrl; - private List mediaQueued = new ArrayList<>(); - private CountUpDownLatch waitForMediaLatch; - private NewPoll poll; - private Status.Visibility statusVisibility; // The current values of the options that will be applied - private boolean statusMarkSensitive; // to the status being composed. - private boolean statusHideText; - private String startingText = ""; - private String startingContentWarning = ""; - private InputContentInfoCompat currentInputContentInfo; - private int currentFlags; - private Uri photoUploadUri; - private int savedTootUid = 0; - private List emojiList; - private CountDownLatch emojiListRetrievalLatch = new CountDownLatch(1); - private int maximumTootCharacters = STATUS_CHARACTER_LIMIT; - private Integer maxPollOptions = null; - private Integer maxPollOptionLength = null; - private @Px - int thumbnailViewSize; - private boolean tootRightNow = false; - - private SaveTootHelper saveTootHelper; - private Gson gson = new Gson(); - - private SharedPreferences preferences; - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - preferences = PreferenceManager.getDefaultSharedPreferences(this); - String theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT); - if (theme.equals("black")) { - setTheme(R.style.TuskyDialogActivityBlackTheme); - } - setContentView(R.layout.activity_compose); - - replyTextView = findViewById(R.id.composeReplyView); - replyContentTextView = findViewById(R.id.composeReplyContentView); - TextView quoteTextView = findViewById(R.id.composeQuoteView); - textEditor = findViewById(R.id.composeEditField); - mediaPreviewBar = findViewById(R.id.compose_media_preview_bar); - contentWarningBar = findViewById(R.id.composeContentWarningBar); - contentWarningEditor = findViewById(R.id.composeContentWarningField); - useDefaultTag = findViewById(R.id.checkbox_use_default_text); - defaultTagEditText = findViewById(R.id.edittext_default_text); - charactersLeft = findViewById(R.id.composeCharactersLeftView); - tootButton = findViewById(R.id.composeTootButton); - pickButton = findViewById(R.id.composeAddMediaButton); - visibilityButton = findViewById(R.id.composeToggleVisibilityButton); - contentWarningButton = findViewById(R.id.composeContentWarningButton); - emojiButton = findViewById(R.id.composeEmojiButton); - hideMediaToggle = findViewById(R.id.composeHideMediaButton); - scheduleButton = findViewById(R.id.composeScheduleButton); - scheduleView = findViewById(R.id.composeScheduleView); - emojiView = findViewById(R.id.emojiView); - emojiList = Collections.emptyList(); - atButton = findViewById(R.id.atButton); - hashButton = findViewById(R.id.hashButton); - - saveTootHelper = new SaveTootHelper(database.tootDao(), this); - - // Setup the toolbar. - Toolbar toolbar = findViewById(R.id.toolbar); - setSupportActionBar(toolbar); - ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setTitle(null); - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setDisplayShowHomeEnabled(true); - Drawable closeIcon = AppCompatResources.getDrawable(this, R.drawable.ic_close_24dp); - ThemeUtils.setDrawableTint(this, closeIcon, R.attr.compose_close_button_tint); - actionBar.setHomeAsUpIndicator(closeIcon); - } - - // setup the account image - final AccountEntity activeAccount = accountManager.getActiveAccount(); - - boolean loadInstanceData = true; - - if (preferences.getBoolean("limitedBandwidthActive", false)) { - loadInstanceData = false; - if (preferences.getBoolean("limitedBandwidthOnlyMobileNetwork", true)) { - ConnectivityManager connectivityManager = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE); - NetworkInfo info = connectivityManager.getActiveNetworkInfo(); - if (info != null && info.getType() == ConnectivityManager.TYPE_WIFI) { - loadInstanceData = true; - } - } - } - - /* 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. */ - Intent intent = getIntent(); - - loadInstanceData = ( loadInstanceData && !intent.getBooleanExtra(TOOT_RIGHT_NOW, false) ); - - if (activeAccount != null) { - ImageView composeAvatar = findViewById(R.id.composeAvatar); - - - int[] actionBarSizeAttr = new int[] { R.attr.actionBarSize }; - TypedArray a = obtainStyledAttributes(null, actionBarSizeAttr); - int avatarSize = a.getDimensionPixelSize(0, 1); - a.recycle(); - - boolean animateAvatars = preferences.getBoolean("animateGifAvatars", false); - - ImageLoadingHelper.loadAvatar( - activeAccount.getProfilePictureUrl(), - composeAvatar, - avatarSize / 8, - animateAvatars - ); - - composeAvatar.setContentDescription( - getString(R.string.compose_active_account_description, - activeAccount.getFullName())); - - if (loadInstanceData) { - mastodonApi.getInstance() - .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe(this::onFetchInstanceSuccess, this::onFetchInstanceFailure); - - mastodonApi.getCustomEmojis().enqueue(new Callback>() { - @Override - public void onResponse(@NonNull Call> call, @NonNull Response> response) { - List emojiList = response.body(); - if (emojiList == null) { - emojiList = Collections.emptyList(); - } - Collections.sort(emojiList, (a, b) -> - a.getShortcode().toLowerCase(Locale.ROOT).compareTo( - b.getShortcode().toLowerCase(Locale.ROOT))); - setEmojiList(emojiList); - cacheInstanceMetadata(activeAccount); - } - - @Override - public void onFailure(@NonNull Call> call, @NonNull Throwable t) { - Log.w(TAG, "error loading custom emojis", t); - loadCachedInstanceMetadata(activeAccount); - } - }); - } else { - loadCachedInstanceMetadata(activeAccount); - } - } else { - // do not do anything when not logged in, activity will be finished in super.onCreate() anyway - return; - } - - composeOptionsView = findViewById(R.id.composeOptionsBottomSheet); - if (Arrays.asList(CAN_USE_UNLEAKABLE).contains(activeAccount.getDomain())) { - composeOptionsView.allowUnleakable(true); - } - composeOptionsView.setListener(this); - - composeOptionsBehavior = BottomSheetBehavior.from(composeOptionsView); - composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - - addMediaBehavior = BottomSheetBehavior.from(findViewById(R.id.addMediaBottomSheet)); - - scheduleBehavior = BottomSheetBehavior.from(scheduleView); - - emojiBehavior = BottomSheetBehavior.from(emojiView); - - emojiView.setLayoutManager(new GridLayoutManager(this, 3, GridLayoutManager.HORIZONTAL, false)); - - enableButton(emojiButton, !loadInstanceData, !loadInstanceData); - - restoreDefaultTagStatus(); - useDefaultTag.setOnCheckedChangeListener((compoundButton, b) -> saveDefaultTagStatus()); - defaultTagEditText.setOnFocusChangeListener((view, b) -> saveDefaultTagStatus()); - - // Setup the interface buttons. - tootButton.setOnClickListener(v -> onSendClicked()); - pickButton.setOnClickListener(v -> openPickDialog()); - visibilityButton.setOnClickListener(v -> showComposeOptions()); - contentWarningButton.setOnClickListener(v -> onContentWarningChanged()); - emojiButton.setOnClickListener(v -> showEmojis()); - hideMediaToggle.setOnClickListener(v -> toggleHideMedia()); - scheduleButton.setOnClickListener(v -> showScheduleView()); - scheduleView.setResetOnClickListener(v -> resetSchedule()); - atButton.setOnClickListener(v -> atButtonClicked()); - hashButton.setOnClickListener(v -> hashButtonClicked()); - - TextView actionPhotoTake = findViewById(R.id.action_photo_take); - TextView actionPhotoPick = findViewById(R.id.action_photo_pick); - actionAddPoll = findViewById(R.id.action_add_poll); - - int textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary); - - Drawable cameraIcon = new IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).color(textColor).sizeDp(18); - actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null); - - Drawable imageIcon = new IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).color(textColor).sizeDp(18); - actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(imageIcon, null, null, null); - - Drawable pollIcon = new IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).color(textColor).sizeDp(18); - actionAddPoll.setCompoundDrawablesRelativeWithIntrinsicBounds(pollIcon, null, null, null); - - actionPhotoTake.setOnClickListener(v -> initiateCameraApp()); - actionPhotoPick.setOnClickListener(v -> onMediaPick()); - actionAddPoll.setOnClickListener(v -> openPollDialog()); - - thumbnailViewSize = getResources().getDimensionPixelSize(R.dimen.compose_media_preview_size); - - /* Initialise all the state, or restore it from a previous run, to determine a "starting" - * state. */ - Status.Visibility startingVisibility = Status.Visibility.UNKNOWN; - boolean startingHideText; - ArrayList savedMediaQueued = null; - if (savedInstanceState != null) { - startingVisibility = Status.Visibility.byNum( - savedInstanceState.getInt("statusVisibility", - Status.Visibility.PUBLIC.getNum()) - ); - statusMarkSensitive = savedInstanceState.getBoolean("statusMarkSensitive"); - startingHideText = savedInstanceState.getBoolean("statusHideText"); - // Keep these until everything needed to put them in the queue is finished initializing. - savedMediaQueued = savedInstanceState.getParcelableArrayList("savedMediaQueued"); - // These are for restoring an in-progress commit content operation. - InputContentInfoCompat previousInputContentInfo = InputContentInfoCompat.wrap( - savedInstanceState.getParcelable("commitContentInputContentInfo")); - int previousFlags = savedInstanceState.getInt("commitContentFlags"); - if (previousInputContentInfo != null) { - onCommitContentInternal(previousInputContentInfo, previousFlags); - } - photoUploadUri = savedInstanceState.getParcelable("photoUploadUri"); - } else { - statusMarkSensitive = activeAccount.getDefaultMediaSensitivity(); - startingHideText = false; - photoUploadUri = null; - } - - String[] mentionedUsernames = null; - ArrayList loadedDraftMediaUris = null; - ArrayList loadedDraftMediaDescriptions = null; - ArrayList mediaAttachments = null; - inReplyToId = null; - quoteId = null; - quoteUrl = null; - if (intent != null) { - - if (startingVisibility == Status.Visibility.UNKNOWN) { - Status.Visibility preferredVisibility = activeAccount.getDefaultPostPrivacy(); - Status.Visibility replyVisibility = Status.Visibility.byNum( - intent.getIntExtra(REPLY_VISIBILITY_EXTRA, Status.Visibility.UNKNOWN.getNum())); - - startingVisibility = Status.Visibility.byNum(Math.max(preferredVisibility.getNum(), replyVisibility.getNum())); - } - - inReplyToId = intent.getStringExtra(IN_REPLY_TO_ID_EXTRA); - - quoteId = intent.getStringExtra(QUOTE_ID_EXTRA); - - if (intent.hasExtra(QUOTE_URL_EXTRA)) { - quoteTextView.setVisibility(View.VISIBLE); - quoteUrl = intent.getStringExtra(QUOTE_URL_EXTRA); - quoteTextView.setText(String.format(getString(R.string.quote_to), quoteUrl)); - } - - mentionedUsernames = intent.getStringArrayExtra(MENTIONED_USERNAMES_EXTRA); - - String contentWarning = intent.getStringExtra(CONTENT_WARNING_EXTRA); - if (contentWarning != null) { - startingHideText = !contentWarning.isEmpty(); - if (startingHideText) { - startingContentWarning = contentWarning; - } - } - - String tootText = intent.getStringExtra(TOOT_TEXT_EXTRA); - if (!TextUtils.isEmpty(tootText)) { - textEditor.setText(tootText); - } - - // try to redo a list of media - // If come from SavedTootActivity - String savedJsonUrls = intent.getStringExtra(SAVED_JSON_URLS_EXTRA); - String savedJsonDescriptions = intent.getStringExtra(SAVED_JSON_DESCRIPTIONS_EXTRA); - if (!TextUtils.isEmpty(savedJsonUrls)) { - loadedDraftMediaUris = gson.fromJson(savedJsonUrls, - new TypeToken>() { - }.getType()); - } - if (!TextUtils.isEmpty(savedJsonDescriptions)) { - loadedDraftMediaDescriptions = gson.fromJson(savedJsonDescriptions, - new TypeToken>() { - }.getType()); - } - // If come from redraft - mediaAttachments = intent.getParcelableArrayListExtra(MEDIA_ATTACHMENTS_EXTRA); - - int savedTootUid = intent.getIntExtra(SAVED_TOOT_UID_EXTRA, 0); - if (savedTootUid != 0) { - this.savedTootUid = savedTootUid; - - // If come from SavedTootActivity - startingText = tootText; - } - - int tootVisibility = intent.getIntExtra(TOOT_VISIBILITY_EXTRA, Status.Visibility.UNKNOWN.getNum()); - if (tootVisibility != Status.Visibility.UNKNOWN.getNum()) { - startingVisibility = Status.Visibility.byNum(tootVisibility); - } - - if (intent.hasExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA)) { - replyTextView.setVisibility(View.VISIBLE); - String username = intent.getStringExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA); - replyTextView.setText(getString(R.string.replying_to, username)); - Drawable arrowDownIcon = new IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_down).sizeDp(12); - - ThemeUtils.setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary); - replyTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null); - - replyTextView.setOnClickListener(v -> { - TransitionManager.beginDelayedTransition((ViewGroup) replyContentTextView.getParent()); - - if (replyContentTextView.getVisibility() != View.VISIBLE) { - replyContentTextView.setVisibility(View.VISIBLE); - Drawable arrowUpIcon = new IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_up).sizeDp(12); - - ThemeUtils.setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary); - replyTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowUpIcon, null); - } else { - replyContentTextView.setVisibility(View.GONE); - replyTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null); - } - }); - } - - if (intent.hasExtra(REPLYING_STATUS_CONTENT_EXTRA)) { - replyContentTextView.setText(intent.getStringExtra(REPLYING_STATUS_CONTENT_EXTRA)); - } - - String scheduledAt = intent.getStringExtra(SCHEDULED_AT_EXTRA); - if (!TextUtils.isEmpty(scheduledAt)) { - scheduleView.setDateTime(scheduledAt); - } - - statusMarkSensitive = intent.getBooleanExtra(SENSITIVE_EXTRA, statusMarkSensitive); - - if(intent.hasExtra(POLL_EXTRA) && (mediaAttachments == null || mediaAttachments.size() == 0)) { - updatePoll(intent.getParcelableExtra(POLL_EXTRA)); - } - - if(mediaAttachments != null && mediaAttachments.size() > 0) { - enablePollButton(false); - } - - tootRightNow = intent.getBooleanExtra(TOOT_RIGHT_NOW, false); - } - - // After the starting state is finalised, the interface can be set to reflect this state. - setStatusVisibility(startingVisibility); - - updateHideMediaToggle(); - updateScheduleButton(); - updateVisibleCharactersLeft(); - - // Setup the main text field. - textEditor.setOnCommitContentListener(this); - final int mentionColour = textEditor.getLinkTextColors().getDefaultColor(); - SpanUtilsKt.highlightSpans(textEditor.getText(), mentionColour); - textEditor.addTextChangedListener(new TextWatcher() { - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - } - - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void afterTextChanged(Editable editable) { - SpanUtilsKt.highlightSpans(editable, mentionColour); - updateVisibleCharactersLeft(); - } - }); - - textEditor.setOnKeyListener((view, keyCode, event) -> this.onKeyDown(keyCode, event)); - - textEditor.setAdapter( - new ComposeAutoCompleteAdapter(this)); - textEditor.setTokenizer(new ComposeTokenizer()); - - // Add any mentions to the text field when a reply is first composed. - if (mentionedUsernames != null) { - StringBuilder builder = new StringBuilder(); - for (String name : mentionedUsernames) { - builder.append('@'); - builder.append(name); - builder.append(' '); - } - startingText = builder.toString() + textEditor.getText(); - textEditor.setText(startingText); - textEditor.setSelection(textEditor.length()); - } - - // 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) { - textEditor.setLayerType(View.LAYER_TYPE_SOFTWARE, null); - } - - // Initialise the content warning editor. - contentWarningEditor.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - updateVisibleCharactersLeft(); - } - - @Override - public void afterTextChanged(Editable s) { - } - }); - showContentWarning(startingHideText); - if (startingContentWarning != null) { - contentWarningEditor.setText(startingContentWarning); - } - - // Initialise the empty media queue state. - waitForMediaLatch = new CountUpDownLatch(); - - // These can only be added after everything affected by the media queue is initialized. - if (!ListUtils.isEmpty(loadedDraftMediaUris)) { - for (int mediaIndex = 0; mediaIndex < loadedDraftMediaUris.size(); ++mediaIndex) { - Uri uri = Uri.parse(loadedDraftMediaUris.get(mediaIndex)); - long mediaSize = getMediaSize(getContentResolver(), uri); - String description = null; - if (loadedDraftMediaDescriptions != null && mediaIndex < loadedDraftMediaDescriptions.size()) { - description = loadedDraftMediaDescriptions.get(mediaIndex); - } - pickMedia(uri, mediaSize, description); - } - } else if (!ListUtils.isEmpty(mediaAttachments)) { - for (int mediaIndex = 0; mediaIndex < mediaAttachments.size(); ++mediaIndex) { - Attachment media = mediaAttachments.get(mediaIndex); - QueuedMedia.Type type; - switch (media.getType()) { - case UNKNOWN: - case IMAGE: - default: { - type = QueuedMedia.Type.IMAGE; - break; - } - case VIDEO: - case GIFV: { - type = QueuedMedia.Type.VIDEO; - break; - } - } - addMediaToQueue(media.getId(), type, media.getPreviewUrl(), media.getDescription()); - } - } else if (savedMediaQueued != null) { - for (SavedQueuedMedia item : savedMediaQueued) { - Bitmap preview = getImageThumbnail(getContentResolver(), item.uri, thumbnailViewSize); - addMediaToQueue(item.id, item.type, preview, item.uri, item.mediaSize, item.readyStage, item.description); - } - } else 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. */ - String type = intent.getType(); - if (type != null) { - if (type.startsWith("image/") || type.startsWith("video/")) { - List uriList = new ArrayList<>(); - if (intent.getAction() != null) { - switch (intent.getAction()) { - case Intent.ACTION_SEND: { - Uri uri = intent.getParcelableExtra(Intent.EXTRA_STREAM); - if (uri != null) { - uriList.add(uri); - } - break; - } - case Intent.ACTION_SEND_MULTIPLE: { - ArrayList list = intent.getParcelableArrayListExtra( - Intent.EXTRA_STREAM); - if (list != null) { - for (Uri uri : list) { - if (uri != null) { - uriList.add(uri); - } - } - } - break; - } - } - } - for (Uri uri : uriList) { - long mediaSize = getMediaSize(getContentResolver(), uri); - pickMedia(uri, mediaSize, null); - } - } else if (type.equals("text/plain")) { - String action = intent.getAction(); - if (action != null && action.equals(Intent.ACTION_SEND)) { - String subject = intent.getStringExtra(Intent.EXTRA_SUBJECT); - String text = intent.getStringExtra(Intent.EXTRA_TEXT); - String shareBody = null; - if (subject != null && text != null) { - if (!subject.equals(text) && !text.contains(subject)) { - shareBody = String.format("%s\n%s", subject, text); - } else { - shareBody = text; - } - } else if (text != null) { - shareBody = text; - } else if (subject != null) { - shareBody = subject; - } - - if (shareBody != null) { - int start = Math.max(textEditor.getSelectionStart(), 0); - int end = Math.max(textEditor.getSelectionEnd(), 0); - int left = Math.min(start, end); - int right = Math.max(start, end); - textEditor.getText().replace(left, right, shareBody, 0, shareBody.length()); - } - } - } - } - } - for (QueuedMedia item : mediaQueued) { - item.preview.setChecked(!TextUtils.isEmpty(item.description)); - } - - textEditor.requestFocus(); - - if (tootRightNow && calculateTextLength() > 0) { - onSendClicked(); - } - } - - private void replaceTextAtCaret(CharSequence text) { - // If you select "backward" in an editable, you get SelectionStart > SelectionEnd - int start = Math.min(textEditor.getSelectionStart(), textEditor.getSelectionEnd()); - int end = Math.max(textEditor.getSelectionStart(), textEditor.getSelectionEnd()); - textEditor.getText().replace(start, end, text); - - // Set the cursor after the inserted text - textEditor.setSelection(start + text.length()); - } - - private void atButtonClicked() { - replaceTextAtCaret("@"); - } - - private void hashButtonClicked() { - replaceTextAtCaret("#"); - } - - @Override - protected void onSaveInstanceState(Bundle outState) { - ArrayList savedMediaQueued = new ArrayList<>(); - for (QueuedMedia item : mediaQueued) { - savedMediaQueued.add(new SavedQueuedMedia(item.id, item.type, item.uri, - item.mediaSize, item.readyStage, item.description)); - } - outState.putParcelableArrayList("savedMediaQueued", savedMediaQueued); - outState.putBoolean("statusMarkSensitive", statusMarkSensitive); - outState.putBoolean("statusHideText", statusHideText); - if (currentInputContentInfo != null) { - outState.putParcelable("commitContentInputContentInfo", - (Parcelable) currentInputContentInfo.unwrap()); - outState.putInt("commitContentFlags", currentFlags); - } - currentInputContentInfo = null; - currentFlags = 0; - outState.putParcelable("photoUploadUri", photoUploadUri); - outState.putInt("statusVisibility", statusVisibility.getNum()); - super.onSaveInstanceState(outState); - } - - private void doErrorDialog(@StringRes int descriptionId, @StringRes int actionId, - View.OnClickListener listener) { - Snackbar bar = Snackbar.make(findViewById(R.id.activity_compose), getString(descriptionId), - Snackbar.LENGTH_SHORT); - bar.setAction(actionId, listener); - //necessary so snackbar is shown over everything - bar.getView().setElevation(getResources().getDimensionPixelSize(R.dimen.compose_activity_snackbar_elevation)); - bar.show(); - } - - private void displayTransientError(@StringRes int stringId) { - Snackbar bar = Snackbar.make(findViewById(R.id.activity_compose), stringId, Snackbar.LENGTH_LONG); - //necessary so snackbar is shown over everything - bar.getView().setElevation(getResources().getDimensionPixelSize(R.dimen.compose_activity_snackbar_elevation)); - bar.show(); - } - - private void toggleHideMedia() { - statusMarkSensitive = !statusMarkSensitive; - updateHideMediaToggle(); - } - - private void updateHideMediaToggle() { - TransitionManager.beginDelayedTransition((ViewGroup) hideMediaToggle.getParent()); - - @ColorInt int color; - if (mediaQueued.size() == 0) { - hideMediaToggle.setVisibility(View.GONE); - } else { - hideMediaToggle.setVisibility(View.VISIBLE); - if (statusMarkSensitive) { - hideMediaToggle.setImageResource(R.drawable.ic_hide_media_24dp); - if (statusHideText) { - hideMediaToggle.setClickable(false); - color = ContextCompat.getColor(this, R.color.compose_media_visible_button_disabled_blue); - } else { - hideMediaToggle.setClickable(true); - color = ContextCompat.getColor(this, R.color.tusky_blue); - } - } else { - hideMediaToggle.setClickable(true); - hideMediaToggle.setImageResource(R.drawable.ic_eye_24dp); - color = ThemeUtils.getColor(this, android.R.attr.textColorTertiary); - } - hideMediaToggle.getDrawable().setColorFilter(color, PorterDuff.Mode.SRC_IN); - } - } - - private void updateScheduleButton() { - @ColorInt int color; - if(scheduleView.getTime() == null) { - color = ThemeUtils.getColor(this, android.R.attr.textColorTertiary); - } else { - color = ContextCompat.getColor(this, R.color.tusky_blue); - } - scheduleButton.getDrawable().setColorFilter(color, PorterDuff.Mode.SRC_IN); - } - - private void disableButtons() { - pickButton.setClickable(false); - visibilityButton.setClickable(false); - emojiButton.setClickable(false); - hideMediaToggle.setClickable(false); - scheduleButton.setClickable(false); - tootButton.setEnabled(false); - } - - private void enableButtons() { - pickButton.setClickable(true); - visibilityButton.setClickable(true); - emojiButton.setClickable(true); - hideMediaToggle.setClickable(true); - scheduleButton.setClickable(true); - tootButton.setEnabled(true); - } - - private void setStatusVisibility(Status.Visibility visibility) { - statusVisibility = visibility; - composeOptionsView.setStatusVisibility(visibility); - tootButton.setStatusVisibility(visibility); - - switch (visibility) { - case PUBLIC: { - Drawable globe = AppCompatResources.getDrawable(this, R.drawable.ic_public_24dp); - if (globe != null) { - visibilityButton.setImageDrawable(globe); - } - break; - } - case PRIVATE: { - Drawable lock = AppCompatResources.getDrawable(this, - R.drawable.ic_lock_outline_24dp); - if (lock != null) { - visibilityButton.setImageDrawable(lock); - } - break; - } - case DIRECT: { - Drawable envelope = AppCompatResources.getDrawable(this, R.drawable.ic_email_24dp); - if (envelope != null) { - visibilityButton.setImageDrawable(envelope); - } - break; - } - case UNLEAKABLE: { - Drawable dontLook = AppCompatResources.getDrawable(this, R.drawable.ic_unleakable_24dp); - if (dontLook != null) { - visibilityButton.setImageDrawable(dontLook); - } - break; - } - case UNLISTED: - default: { - Drawable openLock = AppCompatResources.getDrawable(this, R.drawable.ic_lock_open_24dp); - if (openLock != null) { - visibilityButton.setImageDrawable(openLock); - } - break; - } - } - } - - private void showComposeOptions() { - if (composeOptionsBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN || composeOptionsBehavior.getState() == BottomSheetBehavior.STATE_COLLAPSED) { - composeOptionsBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); - addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } else { - composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } - } - - private void showScheduleView() { - if (scheduleBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN || scheduleBehavior.getState() == BottomSheetBehavior.STATE_COLLAPSED) { - scheduleBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); - composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } else { - scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } - } - - private void showEmojis() { - - if (emojiView.getAdapter() != null) { - if (emojiView.getAdapter().getItemCount() == 0) { - String errorMessage = getString(R.string.error_no_custom_emojis, accountManager.getActiveAccount().getDomain()); - Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show(); - } else { - if (emojiBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.getState() == BottomSheetBehavior.STATE_COLLAPSED) { - emojiBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); - composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } else { - emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } - } - - } - - } - - private void restoreDefaultTagStatus() { - useDefaultTag.setChecked(preferences.getBoolean(PREF_USE_DEFAULT_TAG, false)); - defaultTagEditText.setText(preferences.getString(PREF_DEFAULT_TAG, "")); - } - - private void saveDefaultTagStatus() { - preferences.edit() - .putString(PREF_DEFAULT_TAG, defaultTagEditText.getText().toString()) - .putBoolean(PREF_USE_DEFAULT_TAG, useDefaultTag.isChecked()) - .apply(); - eventHub.dispatch(new PreferenceChangedEvent(PREF_DEFAULT_TAG)); - eventHub.dispatch(new PreferenceChangedEvent(PREF_USE_DEFAULT_TAG)); - } - - private void openPickDialog() { - if (addMediaBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN || addMediaBehavior.getState() == BottomSheetBehavior.STATE_COLLAPSED) { - addMediaBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); - composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } else { - addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } - - } - - private void onMediaPick() { - addMediaBehavior.setBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() { - @Override - public void onStateChanged(@NonNull View bottomSheet, int newState) { - //Wait until bottom sheet is not collapsed and show next screen after - if (newState == BottomSheetBehavior.STATE_COLLAPSED) { - addMediaBehavior.setBottomSheetCallback(null); - if (ContextCompat.checkSelfPermission(ComposeActivity.this, Manifest.permission.READ_EXTERNAL_STORAGE) - != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions(ComposeActivity.this, - new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, - PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE); - } else { - initiateMediaPicking(); - } - } - } - - @Override - public void onSlide(@NonNull View bottomSheet, float slideOffset) { - - } - }); - addMediaBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); - } - - private void openPollDialog() { - addMediaBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); - AddPollDialog.showAddPollDialog(this, poll, maxPollOptions, maxPollOptionLength); - } - - public void updatePoll(NewPoll poll) { - this.poll = poll; - - enableButton(pickButton, false, false); - - if(pollPreview == null) { - - pollPreview = new PollPreviewView(this); - - Resources resources = getResources(); - int margin = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin); - int marginBottom = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom); - - LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); - layoutParams.setMargins(margin, margin, margin, marginBottom); - pollPreview.setLayoutParams(layoutParams); - - mediaPreviewBar.addView(pollPreview); - - pollPreview.setOnClickListener(v -> { - PopupMenu popup = new PopupMenu(this, pollPreview); - final int editId = 1; - final int removeId = 2; - popup.getMenu().add(0, editId, 0, R.string.edit_poll); - popup.getMenu().add(0, removeId, 0, R.string.action_remove); - popup.setOnMenuItemClickListener(menuItem -> { - switch (menuItem.getItemId()) { - case editId: - openPollDialog(); - break; - case removeId: - removePoll(); - break; - } - return true; - }); - popup.show(); - }); - } - - pollPreview.setPoll(poll); - - } - - private void removePoll() { - poll = null; - pollPreview = null; - enableButton(pickButton, true, true); - mediaPreviewBar.removeAllViews(); - } - - @Override - public void onVisibilityChanged(@NonNull Status.Visibility visibility) { - composeOptionsBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); - setStatusVisibility(visibility); - } - - int calculateTextLength() { - int offset = 0; - URLSpan[] urlSpans = textEditor.getUrls(); - if (urlSpans != null) { - for (URLSpan span : urlSpans) { - offset += Math.max(0, span.getURL().length() - MAXIMUM_URL_LENGTH); - } - } - int length = textEditor.length() - offset; - if (statusHideText) { - length += contentWarningEditor.length(); - } - return length; - } - - private void updateVisibleCharactersLeft() { - this.charactersLeft.setText(String.format(Locale.getDefault(), "%d", maximumTootCharacters - calculateTextLength())); - } - - private void onContentWarningChanged() { - boolean showWarning = contentWarningBar.getVisibility() != View.VISIBLE; - showContentWarning(showWarning); - updateVisibleCharactersLeft(); - } - - private void onSendClicked() { - disableButtons(); - readyStatus(statusVisibility, statusMarkSensitive); - } - - @Override - public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) { - try { - if (currentInputContentInfo != null) { - currentInputContentInfo.releasePermission(); - } - } catch (Exception e) { - Log.e(TAG, "InputContentInfoCompat#releasePermission() failed." + e.getMessage()); - } finally { - currentInputContentInfo = null; - } - - // Verify the returned content's type is of the correct MIME type - boolean supported = inputContentInfo.getDescription().hasMimeType("image/*"); - - return supported && onCommitContentInternal(inputContentInfo, flags); - } - - private boolean onCommitContentInternal(InputContentInfoCompat inputContentInfo, int flags) { - if ((flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) { - try { - inputContentInfo.requestPermission(); - } catch (Exception e) { - Log.e(TAG, "InputContentInfoCompat#requestPermission() failed." + e.getMessage()); - return false; - } - } - - // Determine the file size before putting handing it off to be put in the queue. - Uri uri = inputContentInfo.getContentUri(); - long mediaSize; - AssetFileDescriptor descriptor = null; - try { - descriptor = getContentResolver().openAssetFileDescriptor(uri, "r"); - } catch (FileNotFoundException e) { - Log.d(TAG, Log.getStackTraceString(e)); - // Eat this exception, having the descriptor be null is sufficient. - } - if (descriptor != null) { - mediaSize = descriptor.getLength(); - try { - descriptor.close(); - } catch (IOException e) { - // Just eat this exception. - } - } else { - mediaSize = MEDIA_SIZE_UNKNOWN; - } - pickMedia(uri, mediaSize, null); - - currentInputContentInfo = inputContentInfo; - currentFlags = flags; - - return true; - } - - private void sendStatus(String content, Status.Visibility visibility, boolean sensitive, - String spoilerText, @Nullable String quoteId, @Nullable String quoteUrl) { - ArrayList mediaIds = new ArrayList<>(); - ArrayList mediaUris = new ArrayList<>(); - ArrayList mediaDescriptions = new ArrayList<>(); - for (QueuedMedia item : mediaQueued) { - mediaIds.add(item.id); - mediaUris.add(item.uri); - mediaDescriptions.add(item.description); - } - - Intent sendIntent; - AccountEntity activeAccount = accountManager.getActiveAccount(); - - if (activeAccount != null - && !Arrays.asList(CAN_USE_QUOTE_ID).contains(activeAccount.getDomain()) - && quoteUrl != null) { - content += "\n~~~~~~~~~~\n[" + quoteUrl + "]"; - quoteId = null; - } - - sendIntent = SendTootService.sendTootIntent(this, content, spoilerText, - visibility, !mediaUris.isEmpty() && sensitive, mediaIds, mediaUris, mediaDescriptions, - scheduleView.getTime(), inReplyToId, poll, - getIntent().getStringExtra(REPLYING_STATUS_CONTENT_EXTRA), - getIntent().getStringExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA), - getIntent().getStringExtra(SAVED_JSON_URLS_EXTRA), - quoteId, accountManager.getActiveAccount(), savedTootUid); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(sendIntent); - } else { - startService(sendIntent); - } - - finishWithoutSlideOutAnimation(); - - } - - private void readyStatus(final Status.Visibility visibility, final boolean sensitive) { - if (waitForMediaLatch.isEmpty()) { - onReadySuccess(visibility, sensitive); - return; - } - finishingUploadDialog = ProgressDialog.show( - this, getString(R.string.dialog_title_finishing_media_upload), - getString(R.string.dialog_message_uploading_media), true, true); - @SuppressLint("StaticFieldLeak") final AsyncTask waitForMediaTask = - new AsyncTask() { - @Override - protected Boolean doInBackground(Void... params) { - try { - waitForMediaLatch.await(); - } catch (InterruptedException e) { - return false; - } - return true; - } - - @Override - protected void onPostExecute(Boolean successful) { - super.onPostExecute(successful); - finishingUploadDialog.dismiss(); - finishingUploadDialog = null; - if (successful) { - onReadySuccess(visibility, sensitive); - } else { - onReadyFailure(visibility, sensitive); - } - } - - @Override - protected void onCancelled() { - removeAllMediaFromQueue(); - enableButtons(); - super.onCancelled(); - } - }; - finishingUploadDialog.setOnCancelListener(dialog -> { - /* Generating an interrupt by passing true here is important because an interrupt - * exception is the only thing that will kick the latch out of its waiting loop - * early. */ - waitForMediaTask.cancel(true); - }); - waitForMediaTask.execute(); - } - - private void onReadySuccess(Status.Visibility visibility, boolean sensitive) { - /* Validate the status meets the character limit. */ - saveDefaultTagStatus(); - String contentText = useDefaultTag.isChecked() ? - (textEditor.getText().toString() + " " + defaultTagEditText.getText().toString()) : textEditor.getText().toString(); - String spoilerText = ""; - if (statusHideText) { - spoilerText = contentWarningEditor.getText().toString(); - } - int characterCount = calculateTextLength(); - if ((characterCount <= 0 || contentText.trim().length() <= 0) && mediaQueued.size() == 0) { - textEditor.setError(getString(R.string.error_empty)); - enableButtons(); - } else if (characterCount <= maximumTootCharacters) { - sendStatus(contentText, visibility, sensitive, spoilerText, quoteId, quoteUrl); - - } else { - textEditor.setError(getString(R.string.error_compose_character_limit)); - enableButtons(); - } - } - - private void onReadyFailure(final Status.Visibility visibility, final boolean sensitive) { - doErrorDialog(R.string.error_media_upload_sending, R.string.action_retry, - v -> readyStatus(visibility, sensitive)); - enableButtons(); - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[], - @NonNull int[] grantResults) { - switch (requestCode) { - case PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE: { - if (grantResults.length > 0 - && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - initiateMediaPicking(); - } else { - doErrorDialog(R.string.error_media_upload_permission, R.string.action_retry, - v -> onMediaPick()); - } - break; - } - } - } - - @NonNull - private File createNewImageFile() throws IOException { - // Create an image file name - String randomId = StringUtils.randomAlphanumericString(12); - String imageFileName = "Tusky_" + randomId + "_"; - File storageDir = getExternalFilesDir(Environment.DIRECTORY_PICTURES); - return File.createTempFile( - imageFileName, /* prefix */ - ".jpg", /* suffix */ - storageDir /* directory */ - ); - } - - private void initiateCameraApp() { - addMediaBehavior.setState(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. - Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); - if (intent.resolveActivity(getPackageManager()) != null) { - File photoFile = null; - try { - photoFile = createNewImageFile(); - } catch (IOException ex) { - displayTransientError(R.string.error_media_upload_opening); - } - // Continue only if the File was successfully created - if (photoFile != null) { - photoUploadUri = FileProvider.getUriForFile(this, - BuildConfig.APPLICATION_ID + ".fileprovider", - photoFile); - intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUploadUri); - startActivityForResult(intent, MEDIA_TAKE_PHOTO_RESULT); - } - } - } - - private void initiateMediaPicking() { - Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - - String[] mimeTypes = new String[]{"image/*", "video/*"}; - intent.setType("*/*"); - intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); - startActivityForResult(intent, MEDIA_PICK_RESULT); - } - - private void enableButton(ImageButton button, boolean clickable, boolean colorActive) { - button.setEnabled(clickable); - ThemeUtils.setDrawableTint(this, button.getDrawable(), - colorActive ? android.R.attr.textColorTertiary : R.attr.compose_media_button_disabled_tint); - } - - private void enablePollButton(boolean enable) { - actionAddPoll.setEnabled(enable); - int textColor; - if(enable) { - textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary); - } else { - textColor = ThemeUtils.getColor(this, R.attr.compose_media_button_disabled_tint); - } - actionAddPoll.setTextColor(textColor); - actionAddPoll.getCompoundDrawablesRelative()[0].setColorFilter(textColor, PorterDuff.Mode.SRC_IN); - } - - private void addMediaToQueue(QueuedMedia.Type type, Bitmap preview, Uri uri, long mediaSize, @Nullable String description) { - addMediaToQueue(null, type, preview, uri, mediaSize, null, description); - } - - private void addMediaToQueue(String id, QueuedMedia.Type type, String previewUrl, @Nullable String description) { - addMediaToQueue(id, type, null, Uri.parse(previewUrl), 0, - QueuedMedia.ReadyStage.UPLOADED, description); - } - - private void addMediaToQueue(@Nullable String id, QueuedMedia.Type type, Bitmap preview, Uri uri, - long mediaSize, QueuedMedia.ReadyStage readyStage, @Nullable String description) { - final QueuedMedia item = new QueuedMedia(type, uri, new ProgressImageView(this), - mediaSize, description); - item.id = id; - item.readyStage = readyStage; - ImageView view = item.preview; - Resources resources = getResources(); - int margin = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin); - int marginBottom = resources.getDimensionPixelSize( - R.dimen.compose_media_preview_margin_bottom); - LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize); - layoutParams.setMargins(margin, 0, margin, marginBottom); - view.setLayoutParams(layoutParams); - view.setScaleType(ImageView.ScaleType.CENTER_CROP); - if (preview != null) { - view.setImageBitmap(preview); - } else { - Glide.with(this) - .load(uri) - .placeholder(null) - .into(view); - } - view.setOnClickListener(v -> onMediaClick(item, v)); - mediaPreviewBar.addView(view); - mediaQueued.add(item); - updateContentDescription(item); - int queuedCount = mediaQueued.size(); - if (queuedCount == 1) { - // If there's one video in the queue it is full, so disable the button to queue more. - if (item.type == QueuedMedia.Type.VIDEO) { - enableButton(pickButton, false, false); - } - } else if (queuedCount >= Status.MAX_MEDIA_ATTACHMENTS) { - // Limit the total media attachments, also. - enableButton(pickButton, false, false); - } - - updateHideMediaToggle(); - enablePollButton(false); - - if (item.readyStage != QueuedMedia.ReadyStage.UPLOADED) { - waitForMediaLatch.countUp(); - - try { - if (type == QueuedMedia.Type.IMAGE && - (mediaSize > STATUS_IMAGE_SIZE_LIMIT || getImageSquarePixels(getContentResolver(), item.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT)) { - downsizeMedia(item); - } else { - uploadMedia(item); - } - } catch (IOException e) { - onUploadFailure(item, false); - } - } - } - - private void updateContentDescriptionForAllImages() { - List items = new ArrayList<>(mediaQueued); - for (QueuedMedia media : items) { - updateContentDescription(media); - } - } - - private void updateContentDescription(QueuedMedia item) { - if (item.preview != null) { - String imageId; - if (!TextUtils.isEmpty(item.description)) { - imageId = item.description; - } else { - int idx = getImageIdx(item); - if (idx < 0) - imageId = null; - else - imageId = Integer.toString(idx + 1); - } - item.preview.setContentDescription(getString(R.string.compose_preview_image_description, imageId)); - } - } - - private int getImageIdx(QueuedMedia item) { - return mediaQueued.indexOf(item); - } - - private void onMediaClick(QueuedMedia item, View view) { - PopupMenu popup = new PopupMenu(this, view); - final int addCaptionId = 1; - final int removeId = 2; - popup.getMenu().add(0, addCaptionId, 0, R.string.action_set_caption); - popup.getMenu().add(0, removeId, 0, R.string.action_remove); - popup.setOnMenuItemClickListener(menuItem -> { - switch (menuItem.getItemId()) { - case addCaptionId: - makeCaptionDialog(item); - break; - case removeId: - removeMediaFromQueue(item); - break; - } - return true; - }); - popup.show(); - } - - private void makeCaptionDialog(QueuedMedia item) { - LinearLayout dialogLayout = new LinearLayout(this); - int padding = Utils.dpToPx(this, 8); - dialogLayout.setPadding(padding, padding, padding, padding); - - dialogLayout.setOrientation(LinearLayout.VERTICAL); - ImageView imageView = new ImageView(this); - - DisplayMetrics displayMetrics = new DisplayMetrics(); - getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); - - Single.fromCallable(() -> - getSampledBitmap(getContentResolver(), item.uri, displayMetrics.widthPixels, displayMetrics.heightPixels)) - .subscribeOn(Schedulers.computation()) - .observeOn(AndroidSchedulers.mainThread()) - .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe(new SingleObserver() { - @Override - public void onSubscribe(Disposable d) { - } - - @Override - public void onSuccess(Bitmap bitmap) { - imageView.setImageBitmap(bitmap); - } - - @Override - public void onError(Throwable e) { - } - }); - - - int margin = Utils.dpToPx(this, 4); - dialogLayout.addView(imageView); - ((LinearLayout.LayoutParams) imageView.getLayoutParams()).weight = 1; - imageView.getLayoutParams().height = 0; - ((LinearLayout.LayoutParams) imageView.getLayoutParams()).setMargins(0, margin, 0, 0); - - EditText input = new EditText(this); - input.setHint(getString(R.string.hint_describe_for_visually_impaired, MEDIA_DESCRIPTION_CHARACTER_LIMIT)); - dialogLayout.addView(input); - ((LinearLayout.LayoutParams) input.getLayoutParams()).setMargins(margin, margin, margin, margin); - input.setLines(2); - input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES); - input.setText(item.description); - input.setFilters(new InputFilter[]{new InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT)}); - - DialogInterface.OnClickListener okListener = (dialog, which) -> { - Runnable updateDescription = () -> { - mastodonApi.updateMedia(item.id, input.getText().toString()).enqueue(new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - Attachment attachment = response.body(); - if (response.isSuccessful() && attachment != null) { - item.description = attachment.getDescription(); - item.preview.setChecked(item.description != null && !item.description.isEmpty()); - dialog.dismiss(); - updateContentDescription(item); - } else { - showFailedCaptionMessage(); - } - item.updateDescription = null; - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - showFailedCaptionMessage(); - item.updateDescription = null; - } - }); - }; - - if (item.readyStage == QueuedMedia.ReadyStage.UPLOADED) { - updateDescription.run(); - } else { - // media is still uploading, queue description update for when it finishes - item.updateDescription = updateDescription; - } - }; - - AlertDialog dialog = new AlertDialog.Builder(this) - .setView(dialogLayout) - .setPositiveButton(android.R.string.ok, okListener) - .setNegativeButton(android.R.string.cancel, null) - .create(); - - Window window = dialog.getWindow(); - if (window != null) { - window.setSoftInputMode( - WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); - } - - dialog.show(); - } - - private void showFailedCaptionMessage() { - Toast.makeText(this, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show(); - } - - private void removeMediaFromQueue(QueuedMedia item) { - mediaPreviewBar.removeView(item.preview); - mediaQueued.remove(item); - if (mediaQueued.size() == 0) { - updateHideMediaToggle(); - enablePollButton(true); - } - updateContentDescriptionForAllImages(); - enableButton(pickButton, true, true); - cancelReadyingMedia(item); - } - - private void removeAllMediaFromQueue() { - for (Iterator it = mediaQueued.iterator(); it.hasNext(); ) { - QueuedMedia item = it.next(); - it.remove(); - removeMediaFromQueue(item); - } - } - - private void downsizeMedia(final QueuedMedia item) throws IOException { - item.readyStage = QueuedMedia.ReadyStage.DOWNSIZING; - - new DownsizeImageTask(STATUS_IMAGE_SIZE_LIMIT, getContentResolver(), createNewImageFile(), - new DownsizeImageTask.Listener() { - @Override - public void onSuccess(File tempFile) { - item.uri = FileProvider.getUriForFile( - ComposeActivity.this, - BuildConfig.APPLICATION_ID + ".fileprovider", - tempFile); - uploadMedia(item); - } - - @Override - public void onFailure() { - onMediaDownsizeFailure(item); - } - }).execute(item.uri); - } - - private void onMediaDownsizeFailure(QueuedMedia item) { - displayTransientError(R.string.error_image_upload_size); - removeMediaFromQueue(item); - } - - private void uploadMedia(final QueuedMedia item) { - item.readyStage = QueuedMedia.ReadyStage.UPLOADING; - - String mimeType = getContentResolver().getType(item.uri); - MimeTypeMap map = MimeTypeMap.getSingleton(); - String fileExtension = map.getExtensionFromMimeType(mimeType); - final String filename = String.format("%s_%s_%s.%s", - getString(R.string.app_name), - String.valueOf(new Date().getTime()), - StringUtils.randomAlphanumericString(10), - fileExtension); - - InputStream stream; - - try { - stream = getContentResolver().openInputStream(item.uri); - } catch (FileNotFoundException e) { - Log.w(TAG, e); - return; - } - - if (mimeType == null) mimeType = "multipart/form-data"; - - item.preview.setProgress(0); - - ProgressRequestBody fileBody = new ProgressRequestBody(stream, getMediaSize(getContentResolver(), item.uri), MediaType.parse(mimeType), - new ProgressRequestBody.UploadCallback() { // may reference activity longer than I would like to - int lastProgress = -1; - - @Override - public void onProgressUpdate(final int percentage) { - if (percentage != lastProgress) { - runOnUiThread(() -> item.preview.setProgress(percentage)); - } - lastProgress = percentage; - } - }); - - MultipartBody.Part body = MultipartBody.Part.createFormData("file", filename, fileBody); - - item.uploadRequest = mastodonApi.uploadMedia(body); - - item.uploadRequest.enqueue(new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull retrofit2.Response response) { - if (response.isSuccessful()) { - onUploadSuccess(item, response.body()); - if (item.updateDescription != null) { - item.updateDescription.run(); - } - } else { - Log.d(TAG, "Upload request failed. " + response.message()); - onUploadFailure(item, call.isCanceled()); - } - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - Log.d(TAG, "Upload request failed. " + t.getMessage()); - onUploadFailure(item, call.isCanceled()); - item.updateDescription = null; - } - }); - } - - private void onUploadSuccess(final QueuedMedia item, Attachment media) { - item.id = media.getId(); - item.preview.setProgress(-1); - item.readyStage = QueuedMedia.ReadyStage.UPLOADED; - - waitForMediaLatch.countDown(); - } - - private void onUploadFailure(QueuedMedia item, boolean isCanceled) { - if (!isCanceled) { - /* if the upload was voluntarily cancelled, such as if the user clicked on it to remove - * it from the queue, then don't display this error message. */ - displayTransientError(R.string.error_media_upload_sending); - } - if (finishingUploadDialog != null && finishingUploadDialog.isShowing()) { - finishingUploadDialog.cancel(); - } - if (!isCanceled) { - // If it is canceled, it's already been removed, otherwise do it. - removeMediaFromQueue(item); - } - } - - private void cancelReadyingMedia(QueuedMedia item) { - if (item.readyStage == QueuedMedia.ReadyStage.UPLOADING) { - item.uploadRequest.cancel(); - } - if (item.id == null) { - /* The presence of an upload id is used to detect if it finished uploading or not, to - * prevent counting down twice on the same media item. */ - waitForMediaLatch.countDown(); - } - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent intent) { - super.onActivityResult(requestCode, resultCode, intent); - if (resultCode == RESULT_OK && requestCode == MEDIA_PICK_RESULT && intent != null) { - Uri uri = intent.getData(); - long mediaSize = getMediaSize(getContentResolver(), uri); - pickMedia(uri, mediaSize, null); - } else if (resultCode == RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) { - long mediaSize = getMediaSize(getContentResolver(), photoUploadUri); - pickMedia(photoUploadUri, mediaSize, null); - } - } - - - private void pickMedia(Uri inUri, long mediaSize, String description) { - Uri uri = inUri; - ContentResolver contentResolver = getContentResolver(); - String mimeType = contentResolver.getType(uri); - - InputStream tempInput = null; - FileOutputStream out = null; - String filename = inUri.toString().substring(inUri.toString().lastIndexOf("/")); - int suffixPosition = filename.lastIndexOf("."); - String suffix = ""; - if(suffixPosition > 0) suffix = filename.substring(suffixPosition); - try { - tempInput = getContentResolver().openInputStream(inUri); - File file = File.createTempFile("randomTemp1", suffix, getCacheDir()); - out = new FileOutputStream(file.getAbsoluteFile()); - byte[] buff = new byte[1024]; - int read = 0; - while ((read = tempInput.read(buff)) > 0) { - out.write(buff, 0, read); - } - uri = FileProvider.getUriForFile(this, - BuildConfig.APPLICATION_ID+".fileprovider", - file); - mediaSize = getMediaSize(getContentResolver(), uri); - tempInput.close(); - out.close(); - } catch(IOException e) { - Log.w(TAG, e); - uri = inUri; - } finally { - IOUtils.closeQuietly(tempInput); - IOUtils.closeQuietly(out); - } - - if (mediaSize == MEDIA_SIZE_UNKNOWN) { - displayTransientError(R.string.error_media_upload_opening); - return; - } - if (mimeType != null) { - String topLevelType = mimeType.substring(0, mimeType.indexOf('/')); - switch (topLevelType) { - case "video": { - if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) { - displayTransientError(R.string.error_video_upload_size); - return; - } - if (mediaQueued.size() > 0 - && mediaQueued.get(0).type == QueuedMedia.Type.IMAGE) { - displayTransientError(R.string.error_media_upload_image_or_video); - return; - } - Bitmap bitmap = getVideoThumbnail(this, uri, thumbnailViewSize); - if (bitmap != null) { - addMediaToQueue(QueuedMedia.Type.VIDEO, bitmap, uri, mediaSize, description); - } else { - displayTransientError(R.string.error_media_upload_opening); - } - break; - } - case "image": { - Bitmap bitmap = getImageThumbnail(contentResolver, uri, thumbnailViewSize); - if (bitmap != null) { - addMediaToQueue(QueuedMedia.Type.IMAGE, bitmap, uri, mediaSize, description); - } else { - displayTransientError(R.string.error_media_upload_opening); - } - break; - } - default: { - displayTransientError(R.string.error_media_upload_type); - break; - } - } - } else { - displayTransientError(R.string.error_media_upload_type); - } - } - - private void showContentWarning(boolean show) { - statusHideText = show; - TransitionManager.beginDelayedTransition((ViewGroup) contentWarningBar.getParent()); - int color; - if (show) { - statusMarkSensitive = true; - contentWarningBar.setVisibility(View.VISIBLE); - contentWarningEditor.setSelection(contentWarningEditor.getText().length()); - contentWarningEditor.requestFocus(); - color = ContextCompat.getColor(this, R.color.tusky_blue); - } else { - contentWarningBar.setVisibility(View.GONE); - textEditor.requestFocus(); - color = ThemeUtils.getColor(this, android.R.attr.textColorTertiary); - } - contentWarningButton.getDrawable().setColorFilter(color, PorterDuff.Mode.SRC_IN); - - updateHideMediaToggle(); - - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - handleCloseButton(); - return true; - } - - return super.onOptionsItemSelected(item); - } - - - @Override - public void onBackPressed() { - saveDefaultTagStatus(); - // Acting like a teen: deliberately ignoring parent. - if (composeOptionsBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED || - addMediaBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED || - emojiBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED || - scheduleBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { - composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - return; - } - - handleCloseButton(); - } - - - @Override - public boolean onKeyDown(int keyCode, KeyEvent event) { - 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 void handleCloseButton() { - - CharSequence contentText = textEditor.getText(); - CharSequence contentWarning = contentWarningEditor.getText(); - - boolean textChanged = !(TextUtils.isEmpty(contentText) || startingText.startsWith(contentText.toString())); - boolean contentWarningChanged = contentWarningBar.getVisibility() == View.VISIBLE && - !TextUtils.isEmpty(contentWarning) && !startingContentWarning.startsWith(contentWarning.toString()); - boolean mediaChanged = !mediaQueued.isEmpty(); - boolean pollChanged = poll != null; - - if (textChanged || contentWarningChanged || mediaChanged || pollChanged) { - new AlertDialog.Builder(this) - .setMessage(R.string.compose_save_draft) - .setPositiveButton(R.string.action_save, (d, w) -> saveDraftAndFinish()) - .setNegativeButton(R.string.action_delete, (d, w) -> deleteDraftAndFinish()) - .show(); - } else { - finishWithoutSlideOutAnimation(); - } - } - - private void deleteDraftAndFinish() { - for (QueuedMedia media : mediaQueued) { - if (media.uploadRequest != null) - media.uploadRequest.cancel(); - } - finishWithoutSlideOutAnimation(); - } - - private void saveDraftAndFinish() { - ArrayList mediaUris = new ArrayList<>(); - ArrayList mediaDescriptions = new ArrayList<>(); - for (QueuedMedia item : mediaQueued) { - mediaUris.add(item.uri.toString()); - mediaDescriptions.add(item.description); - } - - saveTootHelper.saveToot(textEditor.getText().toString(), - contentWarningEditor.getText().toString(), - getIntent().getStringExtra("saved_json_urls"), - mediaUris, - mediaDescriptions, - savedTootUid, - inReplyToId, - getIntent().getStringExtra(REPLYING_STATUS_CONTENT_EXTRA), - getIntent().getStringExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA), - statusVisibility, - poll); - finishWithoutSlideOutAnimation(); - } - - @Override - public List search(String token) { - switch (token.charAt(0)) { - case '@': - try { - List accountList = mastodonApi - .searchAccounts(token.substring(1), false, 20, null) - .blockingGet(); - return CollectionsKt.map(accountList, - ComposeAutoCompleteAdapter.AccountResult::new); - } catch (Throwable e) { - return Collections.emptyList(); - } - case '#': - try { - SearchResult searchResults = mastodonApi.searchObservable(token, null, false, null, null, null) - .blockingGet(); - return CollectionsKt.map( - searchResults.getHashtags(), - ComposeAutoCompleteAdapter.HashtagResult::new - ); - } catch (Throwable e) { - Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e); - return Collections.emptyList(); - } - case ':': - try { - emojiListRetrievalLatch.await(); - } catch (InterruptedException e) { - Log.e(TAG, String.format("Autocomplete search for %s was interrupted.", token)); - return Collections.emptyList(); - } - if (emojiList != null) { - String incomplete = token.substring(1).toLowerCase(); - - List results = - new ArrayList<>(); - List resultsInside = - new ArrayList<>(); - - for (Emoji emoji : emojiList) { - String shortcode = emoji.getShortcode().toLowerCase(); - - if (shortcode.startsWith(incomplete)) { - results.add(new ComposeAutoCompleteAdapter.EmojiResult(emoji)); - } else if (shortcode.indexOf(incomplete, 1) != -1) { - resultsInside.add(new ComposeAutoCompleteAdapter.EmojiResult(emoji)); - } - } - - if (!results.isEmpty() && !resultsInside.isEmpty()) { - // both lists have results. include a separator between them. - results.add(new ComposeAutoCompleteAdapter.ResultSeparator()); - } - - results.addAll(resultsInside); - return results; - } else { - return Collections.emptyList(); - } - default: - Log.w(TAG, "Unexpected autocompletion token: " + token); - return Collections.emptyList(); - } - } - - @Override - public void onEmojiSelected(@NotNull String shortcode) { - replaceTextAtCaret(":" + shortcode + ": "); - } - - private void loadCachedInstanceMetadata(@NotNull AccountEntity activeAccount) { - InstanceEntity instanceEntity = database.instanceDao() - .loadMetadataForInstance(activeAccount.getDomain()); - - if (instanceEntity != null) { - Integer max = instanceEntity.getMaximumTootCharacters(); - maximumTootCharacters = (max == null ? STATUS_CHARACTER_LIMIT : max); - maxPollOptions = instanceEntity.getMaxPollOptions(); - maxPollOptionLength = instanceEntity.getMaxPollOptionLength(); - setEmojiList(instanceEntity.getEmojiList()); - updateVisibleCharactersLeft(); - } - } - - private void setEmojiList(@Nullable List emojiList) { - this.emojiList = emojiList; - - emojiListRetrievalLatch.countDown(); - - if (emojiList != null) { - emojiView.setAdapter(new EmojiAdapter(emojiList, ComposeActivity.this)); - enableButton(emojiButton, true, emojiList.size() > 0); - } - } - - private void cacheInstanceMetadata(@NotNull AccountEntity activeAccount) { - InstanceEntity instanceEntity = new InstanceEntity( - activeAccount.getDomain(), emojiList, maximumTootCharacters, maxPollOptions, maxPollOptionLength - ); - database.instanceDao().insertOrReplace(instanceEntity); - } - - // Accessors for testing, hence package scope - int getMaximumTootCharacters() { - return maximumTootCharacters; - } - - static boolean canHandleMimeType(@Nullable String mimeType) { - return (mimeType != null && - (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.equals("text/plain"))); - } - - private void onFetchInstanceSuccess(Instance instance) { - if (instance != null) { - - if (instance.getMaxTootChars() != null) { - maximumTootCharacters = instance.getMaxTootChars(); - updateVisibleCharactersLeft(); - } - - if (!new VersionUtils(instance.getVersion()).supportsScheduledToots()) { - scheduleButton.setVisibility(View.GONE); - } - - if (instance.getPollLimits() != null) { - maxPollOptions = instance.getPollLimits().getMaxOptions(); - maxPollOptionLength = instance.getPollLimits().getMaxOptionChars(); - } - - if (!new VersionUtils(instance.getVersion()).supportsScheduledToots()) { - scheduleButton.setVisibility(View.GONE); - } - - cacheInstanceMetadata(accountManager.getActiveAccount()); - } - } - - private void onFetchInstanceFailure(Throwable throwable) { - Log.w(TAG, "error loading instance data", throwable); - loadCachedInstanceMetadata(accountManager.getActiveAccount()); - } - - public static final class QueuedMedia { - Type type; - ProgressImageView preview; - Uri uri; - String id; - Call uploadRequest; - ReadyStage readyStage; - long mediaSize; - String description; - Runnable updateDescription; - - QueuedMedia(Type type, Uri uri, ProgressImageView preview, long mediaSize, - String description) { - this.type = type; - this.uri = uri; - this.preview = preview; - this.mediaSize = mediaSize; - this.description = description; - } - - public enum Type { - IMAGE, - VIDEO - } - - enum ReadyStage { - DOWNSIZING, - UPLOADING, - UPLOADED - } - } - - /** - * This saves enough information to re-enqueue an attachment when restoring the activity. - */ - private static class SavedQueuedMedia implements Parcelable { - public static final Parcelable.Creator CREATOR - = new Parcelable.Creator() { - public SavedQueuedMedia createFromParcel(Parcel in) { - return new SavedQueuedMedia(in); - } - - public SavedQueuedMedia[] newArray(int size) { - return new SavedQueuedMedia[size]; - } - }; - String id; - QueuedMedia.Type type; - Uri uri; - long mediaSize; - QueuedMedia.ReadyStage readyStage; - String description; - - SavedQueuedMedia(String id, QueuedMedia.Type type, Uri uri, long mediaSize, QueuedMedia.ReadyStage readyStage, String description) { - this.id = id; - this.type = type; - this.uri = uri; - this.mediaSize = mediaSize; - this.readyStage = readyStage; - this.description = description; - } - - SavedQueuedMedia(Parcel parcel) { - id = parcel.readString(); - type = (QueuedMedia.Type) parcel.readSerializable(); - uri = parcel.readParcelable(Uri.class.getClassLoader()); - mediaSize = parcel.readLong(); - readyStage = QueuedMedia.ReadyStage.valueOf(parcel.readString()); - description = parcel.readString(); - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeString(id); - dest.writeSerializable(type); - dest.writeParcelable(uri, flags); - dest.writeLong(mediaSize); - dest.writeString(readyStage.name()); - dest.writeString(description); - } - } - - @Override - public void onTimeSet(TimePicker view, int hourOfDay, int minute) { - scheduleView.onTimeSet(hourOfDay, minute); - updateScheduleButton(); - scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } - - public void resetSchedule() { - scheduleView.resetSchedule(); - updateScheduleButton(); - scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } - - public static final class IntentBuilder { - @Nullable - private Integer savedTootUid; - @Nullable - private String tootText; - @Nullable - private String savedJsonUrls; - @Nullable - private String savedJsonDescriptions; - @Nullable - private Collection mentionedUsernames; - @Nullable - private String inReplyToId; - @Nullable - private String quoteId; - @Nullable - private String quoteUrl; - @Nullable - private Status.Visibility replyVisibility; - @Nullable - private Status.Visibility visibility; - @Nullable - private String contentWarning; - @Nullable - private String replyingStatusAuthor; - @Nullable - private String replyingStatusContent; - @Nullable - private ArrayList mediaAttachments; - @Nullable - private String scheduledAt; - @Nullable - private Boolean sensitive; - @Nullable - private NewPoll poll; - @Nullable - private Boolean tootRightNow; - - public IntentBuilder savedTootUid(int uid) { - this.savedTootUid = uid; - return this; - } - - public IntentBuilder tootText(String tootText) { - this.tootText = tootText; - return this; - } - - public IntentBuilder savedJsonUrls(String jsonUrls) { - this.savedJsonUrls = jsonUrls; - return this; - } - - public IntentBuilder savedJsonDescriptions(String jsonDescriptions) { - this.savedJsonDescriptions = jsonDescriptions; - return this; - } - - public IntentBuilder visibility(Status.Visibility visibility) { - this.visibility = visibility; - return this; - } - - public IntentBuilder mentionedUsernames(Collection mentionedUsernames) { - this.mentionedUsernames = mentionedUsernames; - return this; - } - - public IntentBuilder inReplyToId(String inReplyToId) { - this.inReplyToId = inReplyToId; - return this; - } - - public IntentBuilder quoteId(String quoteId) { - this.quoteId = quoteId; - return this; - } - - public IntentBuilder quoteUrl(String quoteUrl) { - this.quoteUrl = quoteUrl; - return this; - } - - public IntentBuilder replyVisibility(Status.Visibility replyVisibility) { - this.replyVisibility = replyVisibility; - return this; - } - - public IntentBuilder contentWarning(String contentWarning) { - this.contentWarning = contentWarning; - return this; - } - - public IntentBuilder replyingStatusAuthor(String username) { - this.replyingStatusAuthor = username; - return this; - } - - public IntentBuilder replyingStatusContent(String content) { - this.replyingStatusContent = content; - return this; - } - - public IntentBuilder mediaAttachments(ArrayList mediaAttachments) { - this.mediaAttachments = mediaAttachments; - return this; - } - - public IntentBuilder scheduledAt(String scheduledAt) { - this.scheduledAt = scheduledAt; - return this; - } - - public IntentBuilder sensitive(boolean sensitive) { - this.sensitive = sensitive; - return this; - } - - public IntentBuilder poll(NewPoll poll) { - this.poll = poll; - return this; - } - - public IntentBuilder tootRightNow(boolean tootRightNow) { - this.tootRightNow = tootRightNow; - return this; - } - - public Intent build(Context context) { - Intent intent = new Intent(context, ComposeActivity.class); - - if (savedTootUid != null) { - intent.putExtra(SAVED_TOOT_UID_EXTRA, (int) savedTootUid); - } - if (tootText != null) { - intent.putExtra(TOOT_TEXT_EXTRA, tootText); - } - if (savedJsonUrls != null) { - intent.putExtra(SAVED_JSON_URLS_EXTRA, savedJsonUrls); - } - if (savedJsonDescriptions != null) { - intent.putExtra(SAVED_JSON_DESCRIPTIONS_EXTRA, savedJsonDescriptions); - } - if (mentionedUsernames != null) { - String[] usernames = mentionedUsernames.toArray(new String[0]); - intent.putExtra(MENTIONED_USERNAMES_EXTRA, usernames); - } - if (inReplyToId != null) { - intent.putExtra(IN_REPLY_TO_ID_EXTRA, inReplyToId); - } - if (quoteId != null) { - intent.putExtra(QUOTE_ID_EXTRA, quoteId); - } - if (quoteUrl != null) { - intent.putExtra(QUOTE_URL_EXTRA, quoteUrl); - } - if (replyVisibility != null) { - intent.putExtra(REPLY_VISIBILITY_EXTRA, replyVisibility.getNum()); - } - if (visibility != null) { - intent.putExtra(TOOT_VISIBILITY_EXTRA, visibility.getNum()); - } - if (contentWarning != null) { - intent.putExtra(CONTENT_WARNING_EXTRA, contentWarning); - } - if (replyingStatusContent != null) { - intent.putExtra(REPLYING_STATUS_CONTENT_EXTRA, replyingStatusContent); - } - if (replyingStatusAuthor != null) { - intent.putExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA, replyingStatusAuthor); - } - if (mediaAttachments != null) { - intent.putParcelableArrayListExtra(MEDIA_ATTACHMENTS_EXTRA, mediaAttachments); - } - if (scheduledAt != null) { - intent.putExtra(SCHEDULED_AT_EXTRA, scheduledAt); - } - if (sensitive != null) { - intent.putExtra(SENSITIVE_EXTRA, sensitive); - } - if (poll != null) { - intent.putExtra(POLL_EXTRA, poll); - } - if (tootRightNow != null) { - intent.putExtra(TOOT_RIGHT_NOW, tootRightNow); - } - return intent; - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt index 7deb2dd7f..fb4f64dfb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.kt @@ -20,14 +20,15 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.net.Uri +import android.os.Build import android.os.Bundle -import androidx.browser.customtabs.CustomTabsIntent import android.text.method.LinkMovementMethod import android.util.Log import android.view.MenuItem import android.view.View import android.widget.TextView import androidx.appcompat.app.AlertDialog +import androidx.browser.customtabs.CustomTabsIntent import com.bumptech.glide.Glide import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.AccessToken @@ -338,9 +339,16 @@ class LoginActivity : BaseActivity(), Injectable { private fun openInCustomTab(uri: Uri, context: Context): Boolean { val toolbarColor = ThemeUtils.getColor(context, R.attr.custom_tab_toolbar) - val customTabsIntent = CustomTabsIntent.Builder() + val customTabsIntentBuilder = CustomTabsIntent.Builder() .setToolbarColor(toolbarColor) - .build() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + customTabsIntentBuilder.setNavigationBarColor( + ThemeUtils.getColor(context, android.R.attr.navigationBarColor) + ) + } + + val customTabsIntent = customTabsIntentBuilder.build() try { customTabsIntent.launchUrl(context, uri) } catch (e: ActivityNotFoundException) { diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java index 28443971a..f6809bae0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java @@ -52,6 +52,7 @@ import com.keylesspalace.tusky.appstore.DrawerFooterClickedEvent; import com.keylesspalace.tusky.appstore.EventHub; import com.keylesspalace.tusky.appstore.MainTabsChangedEvent; import com.keylesspalace.tusky.appstore.ProfileEditedEvent; +import com.keylesspalace.tusky.components.compose.ComposeActivity; import com.keylesspalace.tusky.components.conversation.ConversationsRepository; import com.keylesspalace.tusky.components.search.SearchActivity; import com.keylesspalace.tusky.db.AccountEntity; diff --git a/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java b/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java index 8bf6565bb..8f16726cf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java @@ -22,21 +22,6 @@ import android.view.MenuItem; import android.view.View; import android.widget.TextView; -import com.keylesspalace.tusky.adapter.SavedTootAdapter; -import com.keylesspalace.tusky.appstore.EventHub; -import com.keylesspalace.tusky.appstore.StatusComposedEvent; -import com.keylesspalace.tusky.db.AppDatabase; -import com.keylesspalace.tusky.db.TootDao; -import com.keylesspalace.tusky.db.TootEntity; -import com.keylesspalace.tusky.di.Injectable; -import com.keylesspalace.tusky.util.SaveTootHelper; - -import java.lang.ref.WeakReference; -import java.util.ArrayList; -import java.util.List; - -import javax.inject.Inject; - import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.widget.Toolbar; @@ -44,16 +29,35 @@ import androidx.lifecycle.Lifecycle; import androidx.recyclerview.widget.DividerItemDecoration; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import com.keylesspalace.tusky.adapter.SavedTootAdapter; +import com.keylesspalace.tusky.appstore.EventHub; +import com.keylesspalace.tusky.appstore.StatusComposedEvent; +import com.keylesspalace.tusky.components.compose.ComposeActivity; +import com.keylesspalace.tusky.db.AppDatabase; +import com.keylesspalace.tusky.db.TootDao; +import com.keylesspalace.tusky.db.TootEntity; +import com.keylesspalace.tusky.di.Injectable; +import com.keylesspalace.tusky.util.SaveTootHelper; + +import java.lang.ref.WeakReference; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +import javax.inject.Inject; + import io.reactivex.android.schedulers.AndroidSchedulers; +import static com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions; import static com.uber.autodispose.AutoDispose.autoDisposable; import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from; public final class SavedTootActivity extends BaseActivity implements SavedTootAdapter.SavedTootAction, Injectable { - private SaveTootHelper saveTootHelper; - // ui private SavedTootAdapter adapter; private TextView noContent; @@ -66,13 +70,13 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd EventHub eventHub; @Inject AppDatabase database; + @Inject + SaveTootHelper saveTootHelper; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - saveTootHelper = new SaveTootHelper(database.tootDao(), this); - eventHub.getEvents() .observeOn(AndroidSchedulers.mainThread()) .ofType(StatusComposedEvent.class) @@ -153,18 +157,32 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd @Override public void click(int position, TootEntity item) { - Intent intent = new ComposeActivity.IntentBuilder() - .savedTootUid(item.getUid()) - .tootText(item.getText()) - .contentWarning(item.getContentWarning()) - .savedJsonUrls(item.getUrls()) - .savedJsonDescriptions(item.getDescriptions()) - .inReplyToId(item.getInReplyToId()) - .replyingStatusAuthor(item.getInReplyToUsername()) - .replyingStatusContent(item.getInReplyToText()) - .visibility(item.getVisibility()) - .poll(item.getPoll()) - .build(this); + Gson gson = new Gson(); + Type stringListType = new TypeToken>() {}.getType(); + List jsonUrls = gson.fromJson(item.getUrls(), stringListType); + List descriptions = gson.fromJson(item.getDescriptions(), stringListType); + + ComposeOptions composeOptions = new ComposeOptions( + item.getUid(), + item.getText(), + jsonUrls, + descriptions, + /*mentionedUsernames*/null, + item.getInReplyToId(), + /*quoteId*/null, + /*quoteUrl*/null, + /*replyVisibility*/null, + item.getVisibility(), + item.getContentWarning(), + item.getInReplyToUsername(), + item.getInReplyToText(), + /*mediaAttachments*/null, + /*scheduledAt*/null, + /*sensitive*/null, + /*poll*/null, + false + ); + Intent intent = ComposeActivity.startIntent(this, composeOptions); startActivity(intent); } diff --git a/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt index a7de7a2bf..5467a3b87 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.view.View +import android.view.MenuItem import androidx.appcompat.widget.Toolbar import androidx.lifecycle.Lifecycle import androidx.recyclerview.widget.DividerItemDecoration @@ -11,6 +12,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import com.keylesspalace.tusky.adapter.ScheduledTootAdapter import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.StatusScheduledEvent +import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.network.MastodonApi @@ -79,6 +81,16 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootAdapter.ScheduledToot } } + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressed() + return true + } + } + return super.onOptionsItemSelected(item) + } + fun loadStatuses() { progress_bar.visibility = View.VISIBLE mastodonApi.scheduledStatuses() @@ -135,15 +147,15 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootAdapter.ScheduledToot if (item == null) { return } - val intent = ComposeActivity.IntentBuilder() - .tootText(item.params.text) - .contentWarning(item.params.spoilerText) - .mediaAttachments(item.mediaAttachments) - .inReplyToId(item.params.inReplyToId) - .visibility(item.params.visibility) - .scheduledAt(item.scheduledAt) - .sensitive(item.params.sensitive) - .build(this) + val intent = ComposeActivity.startIntent(this, ComposeActivity.ComposeOptions( + tootText = item.params.text, + contentWarning = item.params.spoilerText, + mediaAttachments = item.mediaAttachments, + inReplyToId = item.params.inReplyToId, + visibility = item.params.visibility, + scheduledAt = item.scheduledAt, + sensitive = item.params.sensitive + )) startActivity(intent) delete(position, item) } diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index 8a2c81c61..f80efae6e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -41,6 +41,7 @@ import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers import kotlinx.android.synthetic.main.activity_tab_preference.* import kotlinx.android.synthetic.main.toolbar_basic.* +import kotlinx.android.synthetic.main.item_tab_preference.view.removeButton import java.util.regex.Pattern import javax.inject.Inject @@ -76,7 +77,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene } currentTabs = (accountManager.activeAccount?.tabPreferences ?: emptyList()).toMutableList() - currentTabsAdapter = TabAdapter(currentTabs, false, this) + currentTabsAdapter = TabAdapter(currentTabs, false, this, currentTabs.size <= MIN_TAB_COUNT) currentTabsRecyclerView.adapter = currentTabsAdapter currentTabsRecyclerView.layoutManager = LinearLayoutManager(this) currentTabsRecyclerView.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL)) @@ -109,10 +110,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - currentTabs.removeAt(viewHolder.adapterPosition) - currentTabsAdapter.notifyItemRemoved(viewHolder.adapterPosition) - updateAvailableTabs() - saveTabs() + onTabRemoved(viewHolder.adapterPosition) } override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { @@ -168,6 +166,13 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene saveTabs() } + override fun onTabRemoved(position: Int) { + currentTabs.removeAt(position) + currentTabsAdapter.notifyItemRemoved(position) + updateAvailableTabs() + saveTabs() + } + override fun onActionChipClicked(tab: TabData) { showEditHashtagDialog(tab) } @@ -273,7 +278,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene addTabAdapter.updateData(addableTabs) maxTabsInfo.visible(addableTabs.size == 0 || currentTabs.size >= MAX_TAB_COUNT) - + currentTabsAdapter.setRemoveButtonVisible(currentTabs.size > MIN_TAB_COUNT); } override fun onStartDelete(viewHolder: RecyclerView.ViewHolder) { diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java index e8b484ca8..6bf824613 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java @@ -72,7 +72,7 @@ public class TuskyApplication extends Application implements HasAndroidInjector AppDatabase.MIGRATION_11_12, AppDatabase.MIGRATION_12_13, AppDatabase.MIGRATION_10_13, AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15, AppDatabase.MIGRATION_15_16, AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19, - AppDatabase.MIGRATION_19_20) + AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21) .build(); accountManager = new AccountManager(appDatabase); serviceLocator = new ServiceLocator() { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt index 594079798..af29f537e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.adapter +import android.text.method.LinkMovementMethod import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -23,15 +24,17 @@ import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Field +import com.keylesspalace.tusky.entity.IdentityProof import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.CustomEmojiHelper +import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.LinkHelper import kotlinx.android.synthetic.main.item_account_field.view.* class AccountFieldAdapter(private val linkListener: LinkListener) : RecyclerView.Adapter() { var emojis: List = emptyList() - var fields: List = emptyList() + var fields: List> = emptyList() override fun getItemCount() = fields.size @@ -41,18 +44,30 @@ class AccountFieldAdapter(private val linkListener: LinkListener) : RecyclerView } override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { - val field = fields[position] + val proofOrField = fields[position] - val emojifiedName = CustomEmojiHelper.emojifyString(field.name, emojis, viewHolder.nameTextView) - viewHolder.nameTextView.text = emojifiedName + if(proofOrField.isLeft()) { + val identityProof = proofOrField.asLeft() - val emojifiedValue = CustomEmojiHelper.emojifyText(field.value, emojis, viewHolder.valueTextView) - LinkHelper.setClickableText(viewHolder.valueTextView, emojifiedValue, null, linkListener, false) + viewHolder.nameTextView.text = identityProof.provider + viewHolder.valueTextView.text = LinkHelper.createClickableText(identityProof.username, identityProof.profileUrl) + + viewHolder.valueTextView.movementMethod = LinkMovementMethod.getInstance() - if(field.verifiedAt != null) { viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) } else { - viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0 ) + val field = proofOrField.asRight() + val emojifiedName = CustomEmojiHelper.emojifyString(field.name, emojis, viewHolder.nameTextView) + viewHolder.nameTextView.text = emojifiedName + + val emojifiedValue = CustomEmojiHelper.emojifyText(field.value, emojis, viewHolder.valueTextView) + LinkHelper.setClickableText(viewHolder.valueTextView, emojifiedValue, null, linkListener, false) + + if(field.verifiedAt != null) { + viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) + } else { + viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0 ) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 71d31af5b..a97693a6c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -189,12 +189,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { @Nullable String spoilerText, @Nullable Status.Mention[] mentions, @NonNull List emojis, + @Nullable PollViewData poll, final StatusActionListener listener, boolean removeQuote) { if (TextUtils.isEmpty(spoilerText)) { contentWarningDescription.setVisibility(View.GONE); contentWarningButton.setVisibility(View.GONE); - this.setTextVisible(true, content, mentions, emojis, listener, removeQuote); + this.setTextVisible(true, content, mentions, emojis, poll, listener, removeQuote); } else { CharSequence emojiSpoiler = CustomEmojiHelper.emojifyString(spoilerText, emojis, contentWarningDescription); contentWarningDescription.setText(emojiSpoiler); @@ -206,9 +207,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (getAdapterPosition() != RecyclerView.NO_POSITION) { listener.onExpandedChange(isChecked, getAdapterPosition()); } - this.setTextVisible(isChecked, content, mentions, emojis, listener, removeQuote); + this.setTextVisible(isChecked, content, mentions, emojis, poll, listener, removeQuote); }); - this.setTextVisible(expanded, content, mentions, emojis, listener, removeQuote); + this.setTextVisible(expanded, content, mentions, emojis, poll, listener, removeQuote); } } @@ -216,11 +217,15 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { Spanned content, Status.Mention[] mentions, List emojis, + @Nullable PollViewData poll, final StatusActionListener listener, boolean removeQuote) { if (expanded) { Spanned emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, this.content); LinkHelper.setClickableText(this.content, emojifiedText, mentions, listener, removeQuote); + if (poll != null) { + setupPoll(poll, emojis, listener); + } } else { LinkHelper.setClickableMentions(this.content, mentions, listener); } @@ -229,6 +234,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } else { this.content.setVisibility(View.VISIBLE); } + setPollVisible(poll != null && expanded); + } + + private void setPollVisible(boolean visible) { + int visibility = visible ? View.VISIBLE : View.GONE; + pollButton.setVisibility(visibility); + pollDescription.setVisibility(visibility); + pollOptions.setVisibility(visibility); } private void setAvatar(String url, @@ -663,40 +676,18 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { replyButton.setEnabled(!isNotestock); replyButton.setClickable(!isNotestock); if (reblogButton != null) { - reblogButton.setEventListener(new SparkEventListener() { - @Override - public void onEvent(ImageView button, boolean buttonState) { - int position = getAdapterPosition(); - if (position != RecyclerView.NO_POSITION) { - listener.onReblog(buttonState, position); - } - } - - @Override - public void onEventAnimationEnd(ImageView button, boolean buttonState) { - } - - @Override - public void onEventAnimationStart(ImageView button, boolean buttonState) { + reblogButton.setEventListener((button, buttonState) -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onReblog(buttonState, position); } }); } - favouriteButton.setEventListener(new SparkEventListener() { - @Override - public void onEvent(ImageView button, boolean buttonState) { - int position = getAdapterPosition(); - if (position != RecyclerView.NO_POSITION) { - listener.onFavourite(buttonState, position); - } - } - - @Override - public void onEventAnimationEnd(ImageView button, boolean buttonState) { - } - - @Override - public void onEventAnimationStart(ImageView button, boolean buttonState) { + favouriteButton.setEventListener((button, buttonState) -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onFavourite(buttonState, position); } }); @@ -712,23 +703,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { }); } - bookmarkButton.setEventListener(new SparkEventListener() { - @Override - public void onEvent(ImageView button, boolean buttonState) { - int position = getAdapterPosition(); - if (position != RecyclerView.NO_POSITION) { - listener.onBookmark(buttonState, position); - } - } - - @Override - public void onEventAnimationEnd(ImageView button, boolean buttonState) { - - } - - @Override - public void onEventAnimationStart(ImageView button, boolean buttonState) { - + bookmarkButton.setEventListener((button, buttonState) -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onBookmark(buttonState, position); } }); @@ -802,13 +780,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setRebloggingEnabled(status.getRebloggingEnabled() && !status.isNotestock(), status.getVisibility()); setQuoteEnabled(status.getRebloggingEnabled() && !status.isNotestock(), status.getVisibility()); - setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), listener, - status.getQuote() != null); + setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), status.getPoll(), listener, status.getQuote() != null); setDescriptionForStatus(status); - setupPoll(status.getPoll(), status.getStatusEmojis(), listener); - // Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0 // RecyclerView tries to set AccessibilityDelegateCompat to null // but ViewCompat code replaces is with the default one. RecyclerView never @@ -963,55 +938,44 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - protected void setupPoll(PollViewData poll, List emojis, StatusActionListener listener) { - if (poll == null) { + private void setupPoll(PollViewData poll, List emojis, StatusActionListener listener) { + long timestamp = System.currentTimeMillis(); - pollOptions.setVisibility(View.GONE); + boolean expired = poll.getExpired() || (poll.getExpiresAt() != null && timestamp > poll.getExpiresAt().getTime()); - pollDescription.setVisibility(View.GONE); + Context context = pollDescription.getContext(); + + pollOptions.setVisibility(View.VISIBLE); + + if (expired || poll.getVoted()) { + // no voting possible + pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), emojis, PollAdapter.RESULT); pollButton.setVisibility(View.GONE); - } else { - long timestamp = System.currentTimeMillis(); + // voting possible + pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), emojis, poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE); - boolean expired = poll.getExpired() || (poll.getExpiresAt() != null && timestamp > poll.getExpiresAt().getTime()); + pollButton.setVisibility(View.VISIBLE); - Context context = pollDescription.getContext(); + pollButton.setOnClickListener(v -> { - pollOptions.setVisibility(View.VISIBLE); + int position = getAdapterPosition(); - if (expired || poll.getVoted()) { - // no voting possible - pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), emojis, PollAdapter.RESULT); + if (position != RecyclerView.NO_POSITION) { - pollButton.setVisibility(View.GONE); - } else { - // voting possible - pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), emojis, poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE); + List pollResult = pollAdapter.getSelected(); - pollButton.setVisibility(View.VISIBLE); - - pollButton.setOnClickListener(v -> { - - int position = getAdapterPosition(); - - if (position != RecyclerView.NO_POSITION) { - - List pollResult = pollAdapter.getSelected(); - - if (!pollResult.isEmpty()) { - listener.onVoteInPoll(position, pollResult); - } + if (!pollResult.isEmpty()) { + listener.onVoteInPoll(position, pollResult); } + } - }); - } - - pollDescription.setVisibility(View.VISIBLE); - pollDescription.setText(getPollInfoText(timestamp, poll, context)); - + }); } + + pollDescription.setVisibility(View.VISIBLE); + pollDescription.setText(getPollInfoText(timestamp, poll, context)); } private CharSequence getPollInfoText(long timestamp, PollViewData poll, Context context) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index 62c5254ac..2c17856cf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -118,10 +118,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder { timestampInfo.append(" • "); if (app.getWebsite() != null) { - URLSpan span = new CustomURLSpan(app.getWebsite()); - - SpannableStringBuilder text = new SpannableStringBuilder(app.getName()); - text.setSpan(span, 0, app.getName().length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + CharSequence text = LinkHelper.createClickableText(app.getName(), app.getWebsite()); timestampInfo.append(text); timestampInfo.setMovementMethod(LinkMovementMethod.getInstance()); } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt index a16898198..bc252ab98 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt @@ -32,14 +32,16 @@ import kotlinx.android.synthetic.main.item_tab_preference.view.* interface ItemInteractionListener { fun onTabAdded(tab: TabData) + fun onTabRemoved(position: Int) fun onStartDelete(viewHolder: RecyclerView.ViewHolder) fun onStartDrag(viewHolder: RecyclerView.ViewHolder) fun onActionChipClicked(tab: TabData) } class TabAdapter(private var data: List, - private val small: Boolean = false, - private val listener: ItemInteractionListener? = null) : RecyclerView.Adapter() { + private val small: Boolean, + private val listener: ItemInteractionListener, + private var removeButtonEnabled: Boolean = false) : RecyclerView.Adapter() { fun updateData(newData: List) { this.data = newData @@ -67,17 +69,28 @@ class TabAdapter(private var data: List, holder.itemView.textView.setCompoundDrawablesRelativeWithIntrinsicBounds(iconDrawable, null, null, null) if (small) { holder.itemView.textView.setOnClickListener { - listener?.onTabAdded(data[position]) + listener.onTabAdded(data[position]) } } holder.itemView.imageView?.setOnTouchListener { _, event -> if (event.action == MotionEvent.ACTION_DOWN) { - listener?.onStartDrag(holder) + listener.onStartDrag(holder) true } else { false } } + holder.itemView.removeButton?.setOnClickListener { + listener.onTabRemoved(holder.adapterPosition) + } + if (holder.itemView.removeButton != null) { + holder.itemView.removeButton.isEnabled = removeButtonEnabled + ThemeUtils.setDrawableTint( + holder.itemView.context, + holder.itemView.removeButton.drawable, + (if (removeButtonEnabled) android.R.attr.textColorTertiary else R.attr.image_button_disabled_tint) + ) + } if (!small) { @@ -89,7 +102,7 @@ class TabAdapter(private var data: List, holder.itemView.actionChip.chipIcon = context.getDrawable(R.drawable.ic_edit_chip) holder.itemView.actionChip.setOnClickListener { - listener?.onActionChipClicked(data[position]) + listener.onActionChipClicked(data[position]) } } else { @@ -102,5 +115,12 @@ class TabAdapter(private var data: List, return data.size } + fun setRemoveButtonVisible(enabled: Boolean) { + if (removeButtonEnabled != enabled) { + removeButtonEnabled = enabled + notifyDataSetChanged() + } + } + class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt new file mode 100644 index 000000000..8fe6e5ed6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -0,0 +1,1081 @@ +/* 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 . */ + +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.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +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.core.widget.doAfterTextChanged +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import androidx.preference.PreferenceManager +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.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog +import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog +import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener +import com.keylesspalace.tusky.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.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 + @Inject + lateinit var eventHub: EventHub + + 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(COMPOSE_OPTIONS_EXTRA) + viewModel.setup(composeOptions) + setupReplyViews(composeOptions?.replyingStatusAuthor) + setupQuoteView(composeOptions?.quoteUrl) + val tootText = composeOptions?.tootText + if (!tootText.isNullOrEmpty()) { + composeEditField.setText(tootText) + } + } + + if (loadInstanceData(preferences)) { + viewModel.loadInstanceDataFromNetwork() + } else { + viewModel.loadInstanceDataFromCache() + } + + if (!TextUtils.isEmpty(composeOptions?.scheduledAt)) { + composeScheduleView.setDateTime(composeOptions?.scheduledAt) + } + + setupComposeField(viewModel.startingText) + setupDefaultTagViews(preferences) + setupContentWarningField(composeOptions?.contentWarning) + setupPollView() + applyShareIntent(intent, savedInstanceState) + + composeEditField.requestFocus() + + if (composeOptions?.tootRightNow == true && calculateTextLength() > 0) { + onSendClicked() + } + } + + private fun loadInstanceData(preferences: SharedPreferences): Boolean { + if (composeOptions?.tootRightNow == true) { + return false // from Quick Toot + } + if (!preferences.getBoolean("limitedBandwidthActive", false)) { + return true // Limited Bandwidth Mode disabled + } + if (!preferences.getBoolean("limitedBandwidthOnlyMobileNetwork", true)) { + return false // Limited Bandwidth Mode enabled && Only Mobile Network disabled + } + val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + @Suppress("DEPRECATION") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = connectivityManager.activeNetwork + if (connectivityManager.getNetworkCapabilities(network)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true) { + return true + } + } else { + val info = connectivityManager.activeNetworkInfo + if (info?.type == ConnectivityManager.TYPE_WIFI) { + return true + } + } + return false + } + + 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() + if (intent.action != null) { + when (intent.action) { + Intent.ACTION_SEND -> { + val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) + if (uri != null) { + uriList.add(uri) + } + } + Intent.ACTION_SEND_MULTIPLE -> { + val list = intent.getParcelableArrayListExtra( + 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 setupQuoteView(quoteUrl: String?) { + if (quoteUrl != null) { + composeQuoteView.show() + composeQuoteView.text = getString(R.string.quote_to, quoteUrl) + } + } + + 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 setupDefaultTagViews(preferences: SharedPreferences) { + checkboxUseDefaultText.isChecked = preferences.getBoolean(PREF_USE_DEFAULT_TAG, false) + checkboxUseDefaultText.setOnCheckedChangeListener { _, isChecked -> + preferences.edit() + .putBoolean(PREF_USE_DEFAULT_TAG, isChecked) + .apply() + eventHub.dispatch(PreferenceChangedEvent(PREF_USE_DEFAULT_TAG)) + } + + editTextDefaultText.setText(preferences.getString(PREF_DEFAULT_TAG, "")) + editTextDefaultText.doAfterTextChanged { + preferences.edit() + .putString(PREF_DEFAULT_TAG, it.toString()) + .apply() + eventHub.dispatch(PreferenceChangedEvent(PREF_DEFAULT_TAG)) + } + } + + private fun setupButtons() { + composeOptionsBottomSheet.listener = this + composeOptionsBottomSheet.allowUnleakable(viewModel.domain in CAN_USE_UNLEAKABLE) + + 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 + Status.Visibility.UNLEAKABLE -> R.drawable.ic_unleakable_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 (checkboxUseDefaultText.isChecked) { + length += 1 + editTextDefaultText.length() + } + 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() { + var 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) { + if (checkboxUseDefaultText.isChecked) { + contentText += " ${editTextDefaultText.text}" + } + 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, + 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 { + return viewModel.searchAutocompleteSuggestions(token) + } + + override fun onEmojiSelected(shortcode: String) { + replaceTextAtCaret(":$shortcode: ") + } + + private fun setEmojiList(emojiList: List?) { + 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? = null, + var mediaDescriptions: List? = null, + var mentionedUsernames: Set? = null, + var inReplyToId: String? = null, + var quoteId: String? = null, + var quoteUrl: 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? = null, + var scheduledAt: String? = null, + var sensitive: Boolean? = null, + var poll: NewPoll? = null, + var tootRightNow: Boolean? = 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 + + @JvmField + val CAN_USE_UNLEAKABLE = arrayOf("itabashi.0j0.jp", "n-sr.org", "odakyu.app") + + const val PREF_DEFAULT_TAG = "default_tag" + const val PREF_USE_DEFAULT_TAG = "use_default_tag" + + @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") + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt new file mode 100644 index 000000000..56d491ea9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -0,0 +1,498 @@ +/* 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 . */ + +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.Attachment +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.NewPoll +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 quoteId: String? = null + private var quoteUrl: String? = null + private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN + + private val instance: MutableLiveData = MutableLiveData() + + val instanceParams: LiveData = 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?> = 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 = mutableLiveData(null) + val scheduledAt: MutableLiveData = mutableLiveData(null) + + val media = mutableLiveData>(listOf()) + val uploadError = MutableLiveData() + + val domain = accountManager.activeAccount?.domain!! + + private val mediaToDisposable = mutableMapOf() + + + fun loadInstanceDataFromNetwork() { + + Singles.zip(api.getCustomEmojis(), api.getInstance()) { emojis, instance -> + InstanceEntity( + instance = 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 loadInstanceDataFromCache() { + 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> { + // 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>() + 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() + val mediaDescriptions = mutableListOf() + 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 { + return media + .filter { items -> items.all { it.uploadPercent == -1 } } + .map { + val mediaIds = ArrayList() + val mediaUris = ArrayList() + val mediaDescriptions = ArrayList() + for (item in media.value!!) { + mediaIds.add(item.id!!) + mediaUris.add(item.uri) + mediaDescriptions.add(item.description ?: "") + } + + var text = content + if (domain !in CAN_USE_QUOTE_ID && quoteId != null) { + text += "\n~~~~~~~~~~\n[$quoteUrl]" + quoteId = null + } + + val tootToSend = TootToSend( + text, + spoilerText, + statusVisibility.value!!.serverString(), + mediaUris.isNotEmpty() && markMediaAsSensitive.value!!, + mediaIds, + mediaUris.map { it.toString() }, + mediaDescriptions, + scheduledAt = scheduledAt.value, + inReplyToId = inReplyToId, + poll = poll.value, + replyingStatusContent = null, + replyingStatusAuthorUsername = null, + savedJsonUrls = null, + quoteId = quoteId, + accountId = accountManager.activeAccount!!.id, + savedTootUid = 0, + idempotencyKey = randomAlphanumericString(16), + retries = 0 + ) + serviceClient.sendToot(tootToSend) + } + } + + fun updateDescription(localId: Long, description: String): LiveData { + 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() + media.observeForever(object : Observer> { + override fun onChanged(mediaItems: List) { + 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 { + 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() + val resultsInside = ArrayList() + 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 + + quoteId = composeOptions?.quoteId + quoteUrl = composeOptions?.quoteUrl + + val contentWarning = composeOptions?.contentWarning + if (contentWarning != null) { + startingContentWarning = contentWarning + } + + // recreate media list + // when coming from SavedTootActivity + val loadedDraftMediaUris = composeOptions?.mediaUrls + val loadedDraftMediaDescriptions: List? = 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) + } + + + savedTootUid = composeOptions?.savedTootUid ?: 0 + startingText = composeOptions?.tootText + + + val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN + if (tootVisibility.num != Status.Visibility.UNKNOWN.num) { + startingVisibility = tootVisibility + } + val builder = StringBuilder() + val mentionedUsernames = composeOptions?.mentionedUsernames + if (mentionedUsernames != null) { + for (name in mentionedUsernames) { + builder.append('@') + builder.append(name) + builder.append(' ') + } + } + if (startingText != null) { + builder.append(startingText) + } + 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 mutableLiveData(default: T) = MutableLiveData().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 + +private val CAN_USE_QUOTE_ID = arrayOf("odakyu.app", "biwakodon.com", "dtp-mstdn.jp", "nitiasa.com", "comm.cx", "fedibird.com") + +data class ComposeInstanceParams( + val maxChars: Int, + val pollMaxOptions: Int, + val pollMaxLength: Int, + val supportsScheduled: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/DownsizeImageTask.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/DownsizeImageTask.java similarity index 88% rename from app/src/main/java/com/keylesspalace/tusky/util/DownsizeImageTask.java rename to app/src/main/java/com/keylesspalace/tusky/components/compose/DownsizeImageTask.java index 364f0e849..880a41679 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/DownsizeImageTask.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/DownsizeImageTask.java @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.util; +package com.keylesspalace.tusky.components.compose; import android.content.ContentResolver; import android.graphics.Bitmap; @@ -21,6 +21,8 @@ import android.graphics.BitmapFactory; import android.net.Uri; import android.os.AsyncTask; +import com.keylesspalace.tusky.util.IOUtils; + import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; @@ -42,10 +44,10 @@ public class DownsizeImageTask extends AsyncTask { 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 tempFile the file where the result will be stored - * @param listener to whom the results are given + * @param tempFile the file where the result will be stored + * @param listener to whom the results are given */ public DownsizeImageTask(int sizeLimit, ContentResolver contentResolver, File tempFile, Listener listener) { this.sizeLimit = sizeLimit; @@ -56,6 +58,25 @@ public class DownsizeImageTask extends AsyncTask { @Override protected Boolean doInBackground(Uri... uris) { + boolean result = DownsizeImageTask.resize(uris, sizeLimit, contentResolver, tempFile); + if (isCancelled()) { + return false; + } + return result; + } + + @Override + protected void onPostExecute(Boolean successful) { + if (successful) { + listener.onSuccess(tempFile); + } else { + listener.onFailure(); + } + super.onPostExecute(successful); + } + + public static boolean resize(Uri[] uris, int sizeLimit, ContentResolver contentResolver, + File tempFile) { for (Uri uri : uris) { InputStream inputStream; try { @@ -118,27 +139,16 @@ public class DownsizeImageTask extends AsyncTask { reorientedBitmap.recycle(); scaledImageSize /= 2; } while (tempFile.length() > sizeLimit); - - if (isCancelled()) { - return false; - } } return true; } - @Override - protected void onPostExecute(Boolean successful) { - if (successful) { - listener.onSuccess(tempFile); - } else { - listener.onFailure(); - } - super.onPostExecute(successful); - } - - /** Used to communicate the results of the task. */ + /** + * Used to communicate the results of the task. + */ public interface Listener { void onSuccess(File file); + void onFailure(); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt new file mode 100644 index 000000000..babb0a391 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt @@ -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 . */ + +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() { + + fun submitList(list: List) { + 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() { + 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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt new file mode 100644 index 000000000..af41f4bbd --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt @@ -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 . */ + +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 + fun uploadMedia(media: QueuedMedia): Observable +} + +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 { + return Observable + .fromCallable { + if (shouldResizeMedia(media)) { + downsize(media) + } + media + } + .switchMap { upload(it) } + .subscribeOn(Schedulers.io()) + } + + override fun prepareMedia(inUri: Uri): Single { + 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 { + 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 + + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/AddPollDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt similarity index 69% rename from app/src/main/java/com/keylesspalace/tusky/view/AddPollDialog.kt rename to app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt index 3ed211b2a..d0f98bac6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/AddPollDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt @@ -15,29 +15,28 @@ @file:JvmName("AddPollDialog") -package com.keylesspalace.tusky.view +package com.keylesspalace.tusky.components.compose.dialog +import android.content.Context +import android.view.LayoutInflater +import android.view.WindowManager import androidx.appcompat.app.AlertDialog -import com.keylesspalace.tusky.ComposeActivity +import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.AddPollOptionsAdapter import com.keylesspalace.tusky.entity.NewPoll import kotlinx.android.synthetic.main.dialog_add_poll.view.* -import android.view.WindowManager -import com.keylesspalace.tusky.R - -private const val DEFAULT_MAX_OPTION_COUNT = 4 -private const val DEFAULT_MAX_OPTION_LENGTH = 25 fun showAddPollDialog( - activity: ComposeActivity, + context: Context, poll: NewPoll?, - maxOptionCount: Int?, - maxOptionLength: Int? + maxOptionCount: Int, + maxOptionLength: Int, + onUpdatePoll: (NewPoll) -> Unit ) { - val view = activity.layoutInflater.inflate(R.layout.dialog_add_poll, null) + val view = LayoutInflater.from(context).inflate(R.layout.dialog_add_poll, null) - val dialog = AlertDialog.Builder(activity) + val dialog = AlertDialog.Builder(context) .setIcon(R.drawable.ic_poll_24dp) .setTitle(R.string.create_poll_title) .setView(view) @@ -47,7 +46,7 @@ fun showAddPollDialog( val adapter = AddPollOptionsAdapter( options = poll?.options?.toMutableList() ?: mutableListOf("", ""), - maxOptionLength = maxOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, + maxOptionLength = maxOptionLength, onOptionRemoved = { valid -> view.addChoiceButton.isEnabled = true dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid @@ -60,15 +59,15 @@ fun showAddPollDialog( view.pollChoices.adapter = adapter view.addChoiceButton.setOnClickListener { - if (adapter.itemCount < maxOptionCount ?: DEFAULT_MAX_OPTION_COUNT) { + if (adapter.itemCount < maxOptionCount) { adapter.addChoice() } - if (adapter.itemCount >= maxOptionCount ?: DEFAULT_MAX_OPTION_COUNT) { + if (adapter.itemCount >= maxOptionCount) { it.isEnabled = false } } - val pollDurationId = activity.resources.getIntArray(R.array.poll_duration_values).indexOfLast { + val pollDurationId = context.resources.getIntArray(R.array.poll_duration_values).indexOfLast { it <= poll?.expiresIn ?: 0 } @@ -81,15 +80,14 @@ fun showAddPollDialog( button.setOnClickListener { val selectedPollDurationId = view.pollDurationSpinner.selectedItemPosition - val pollDuration = activity.resources.getIntArray(R.array.poll_duration_values)[selectedPollDurationId] + val pollDuration = context.resources + .getIntArray(R.array.poll_duration_values)[selectedPollDurationId] - activity.updatePoll( - NewPoll( - options = adapter.pollOptions, - expiresIn = pollDuration, - multiple = view.multipleChoicesCheckBox.isChecked - ) - ) + onUpdatePoll(NewPoll( + options = adapter.pollOptions, + expiresIn = pollDuration, + multiple = view.multipleChoicesCheckBox.isChecked + )) dialog.dismiss() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt new file mode 100644 index 000000000..e7cc36cbe --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt @@ -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 . */ + +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.makeCaptionDialog(existingDescription: String?, + previewUri: Uri, + onUpdateDescription: (String) -> LiveData +) 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() { + override fun onLoadCleared(placeholder: Drawable?) {} + + override fun onResourceReady(resource: Drawable, transition: Transition?) { + imageView.setImageDrawable(resource) + } + }) +} + + +private fun Activity.showFailedCaptionMessage() { + Toast.makeText(this, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show() +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ComposeOptionsView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeOptionsView.kt similarity index 98% rename from app/src/main/java/com/keylesspalace/tusky/view/ComposeOptionsView.kt rename to app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeOptionsView.kt index b975f2f67..5b62870d0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/ComposeOptionsView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeOptionsView.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.view +package com.keylesspalace.tusky.components.compose.view import android.content.Context import android.util.AttributeSet diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ComposeScheduleView.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.java similarity index 95% rename from app/src/main/java/com/keylesspalace/tusky/view/ComposeScheduleView.java rename to app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.java index dc58f86ec..af10b2776 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/ComposeScheduleView.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.java @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.view; +package com.keylesspalace.tusky.components.compose.view; import android.content.Context; import android.graphics.drawable.Drawable; @@ -30,6 +30,7 @@ import com.google.android.material.datepicker.DateValidatorPointForward; import com.google.android.material.datepicker.MaterialDatePicker; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.fragment.TimePickerFragment; +import com.keylesspalace.tusky.util.ThemeUtils; import java.text.DateFormat; import java.text.ParseException; @@ -87,7 +88,7 @@ public class ComposeScheduleView extends ConstraintLayout { private void setScheduledDateTime() { if (scheduleDateTime == null) { - scheduledDateTimeView.setText(R.string.hint_configure_scheduled_toot); + scheduledDateTimeView.setText(""); } else { scheduledDateTimeView.setText(String.format("%s %s", dateFormat.format(scheduleDateTime.getTime()), @@ -96,13 +97,13 @@ public class ComposeScheduleView extends ConstraintLayout { } private void setEditIcons() { - final int size = scheduledDateTimeView.getLineHeight(); - - Drawable icon = getContext().getDrawable(R.drawable.ic_create_24dp); + Drawable icon = ThemeUtils.getTintedDrawable(getContext(), R.drawable.ic_create_24dp, android.R.attr.textColorTertiary); if (icon == null) { return; } + final int size = scheduledDateTimeView.getLineHeight(); + icon.setBounds(0, 0, size, size); scheduledDateTimeView.setCompoundDrawables(null, null, icon, null); @@ -117,7 +118,7 @@ public class ComposeScheduleView extends ConstraintLayout { setScheduledDateTime(); } - private void openPickDateDialog() { + public void openPickDateDialog() { long yesterday = Calendar.getInstance().getTimeInMillis() - 24 * 60 * 60 * 1000; CalendarConstraints calendarConstraints = new CalendarConstraints.Builder() .setValidator( diff --git a/app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt similarity index 98% rename from app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.kt rename to app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt index 1ee7e84af..0a5e1c33a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.view +package com.keylesspalace.tusky.components.compose.view import android.content.Context import androidx.emoji.widget.EmojiEditTextHelper diff --git a/app/src/main/java/com/keylesspalace/tusky/view/PollPreviewView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt similarity index 97% rename from app/src/main/java/com/keylesspalace/tusky/view/PollPreviewView.kt rename to app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt index e82831fd2..63e627fc1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/PollPreviewView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.view +package com.keylesspalace.tusky.components.compose.view import android.content.Context import android.util.AttributeSet diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ProgressImageView.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java similarity index 98% rename from app/src/main/java/com/keylesspalace/tusky/view/ProgressImageView.java rename to app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java index bfb474eec..836d81bd0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/ProgressImageView.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.view; +package com.keylesspalace.tusky.components.compose.view; import android.content.Context; import android.graphics.Canvas; diff --git a/app/src/main/java/com/keylesspalace/tusky/view/TootButton.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt similarity index 97% rename from app/src/main/java/com/keylesspalace/tusky/view/TootButton.kt rename to app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt index 333f41c9c..c641f345c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/TootButton.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.view +package com.keylesspalace.tusky.components.compose.view import android.content.Context import android.graphics.Color diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index 560900a13..4897ac338 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -108,14 +108,11 @@ public class ConversationViewHolder extends StatusBaseViewHolder { setupButtons(listener, account.getId(), false, account.getUsername()); - setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getEmojis(), listener, false); + setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getEmojis(), PollViewDataKt.toViewData(status.getPoll()), listener, false); setConversationName(conversation.getAccounts()); setAvatars(conversation.getAccounts()); - - setupPoll(PollViewDataKt.toViewData(status.getPoll()), status.getEmojis(), listener); - } private void setConversationName(List accounts) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 33f5edfeb..1f0f1cfb6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -38,7 +38,12 @@ import androidx.paging.PagedListAdapter import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager -import com.keylesspalace.tusky.* +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.MainActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.ViewMediaActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter import com.keylesspalace.tusky.db.AccountEntity @@ -201,14 +206,14 @@ open class SearchStatusesFragment : SearchFragment + .subscribe({ deletedStatus -> removeItem(position) - val redraftStatus = if(deletedStatus.isEmpty()) { + val redraftStatus = if (deletedStatus.isEmpty()) { status.toDeletedStatus() } else { deletedStatus } - val intent = ComposeActivity.IntentBuilder() - .tootText(redraftStatus.text) - .inReplyToId(redraftStatus.inReplyToId) - .visibility(redraftStatus.visibility) - .contentWarning(redraftStatus.spoilerText) - .mediaAttachments(redraftStatus.attachments) - .sensitive(redraftStatus.sensitive) - .poll(redraftStatus.poll?.toNewPoll(status.createdAt)) - .build(context) + val intent = ComposeActivity.startIntent(context!!, ComposeOptions( + tootText = redraftStatus.text ?: "", + inReplyToId = redraftStatus.inReplyToId, + visibility = redraftStatus.visibility, + contentWarning = redraftStatus.spoilerText, + mediaAttachments = redraftStatus.attachments, + sensitive = redraftStatus.sensitive, + poll = redraftStatus.poll?.toNewPoll(status.createdAt) + )) startActivity(intent) }, { error -> Log.w("SearchStatusesFragment", "error deleting status", error) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index d37df70a2..e4d743c98 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -30,7 +30,7 @@ import androidx.annotation.NonNull; @Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, ConversationEntity.class - }, version = 20) + }, version = 21) public abstract class AppDatabase extends RoomDatabase { public abstract TootDao tootDao(); @@ -316,6 +316,14 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `bookmarked` INTEGER NOT NULL DEFAULT 0"); database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_bookmarked` INTEGER NOT NULL DEFAULT 0"); } + + }; + + public static final Migration MIGRATION_20_21 = new Migration(20, 21) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `version` TEXT"); + } }; } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt index bc87d018f..0c78349ef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt @@ -19,6 +19,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import io.reactivex.Single @Dao interface InstanceDao { @@ -26,5 +27,5 @@ interface InstanceDao { fun insertOrReplace(instance: InstanceEntity) @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") - fun loadMetadataForInstance(instance: String): InstanceEntity? + fun loadMetadataForInstance(instance: String): Single } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt index 0797ffb6a..1e2adaf04 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt @@ -27,5 +27,6 @@ data class InstanceEntity( val emojiList: List?, val maximumTootCharacters: Int?, val maxPollOptions: Int?, - val maxPollOptionLength: Int? + val maxPollOptionLength: Int?, + val version: String? ) diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt index 878bfcb46..17d4a73e5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.di import com.keylesspalace.tusky.* +import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.search.SearchActivity diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt index 996ad5059..ff3d02669 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt @@ -35,7 +35,8 @@ import javax.inject.Singleton ServicesModule::class, BroadcastReceiverModule::class, ViewModelModule::class, - RepositoryModule::class + RepositoryModule::class, + MediaUploaderModule::class ]) interface AppComponent { @Component.Builder diff --git a/app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt new file mode 100644 index 000000000..66dc27110 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt @@ -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 . */ + +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) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt index 9015b5f21..5f6495543 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt @@ -15,12 +15,25 @@ package com.keylesspalace.tusky.di +import android.content.Context import com.keylesspalace.tusky.service.SendTootService +import com.keylesspalace.tusky.service.ServiceClient +import com.keylesspalace.tusky.service.ServiceClientImpl import dagger.Module +import dagger.Provides import dagger.android.ContributesAndroidInjector @Module abstract class ServicesModule { @ContributesAndroidInjector abstract fun contributesSendTootService(): SendTootService + + @Module + companion object { + @Provides + @JvmStatic + fun providesServiceClient(context: Context): ServiceClient { + return ServiceClientImpl(context) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index 3706bc11d..8381d526a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -4,10 +4,13 @@ package com.keylesspalace.tusky.di import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.conversation.ConversationsViewModel import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.search.SearchViewModel -import com.keylesspalace.tusky.viewmodel.* +import com.keylesspalace.tusky.viewmodel.AccountViewModel +import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel +import com.keylesspalace.tusky.viewmodel.EditProfileViewModel import com.keylesspalace.tusky.viewmodel.ListsViewModel import dagger.Binds import dagger.MapKey @@ -71,5 +74,10 @@ abstract class ViewModelModule { @ViewModelKey(SearchViewModel::class) internal abstract fun searchViewModel(viewModel: SearchViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(ComposeViewModel::class) + internal abstract fun composeViewModel(viewModel: ComposeViewModel): ViewModel + //Add more ViewModels here } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt b/app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt new file mode 100644 index 000000000..9473f0372 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt @@ -0,0 +1,9 @@ +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class IdentityProof( + val provider: String, + @SerializedName("provider_username") val username: String, + @SerializedName("profile_url") val profileUrl: String +) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index 3062869d7..6723787f7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -42,12 +42,13 @@ import androidx.lifecycle.Lifecycle; import com.keylesspalace.tusky.BaseActivity; import com.keylesspalace.tusky.BottomSheetActivity; -import com.keylesspalace.tusky.ComposeActivity; import com.keylesspalace.tusky.MainActivity; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.PostLookupFallbackBehavior; import com.keylesspalace.tusky.ViewMediaActivity; import com.keylesspalace.tusky.ViewTagActivity; +import com.keylesspalace.tusky.components.compose.ComposeActivity; +import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions; import com.keylesspalace.tusky.components.report.ReportActivity; import com.keylesspalace.tusky.db.AccountEntity; import com.keylesspalace.tusky.db.AccountManager; @@ -148,21 +149,22 @@ public abstract class SFragment extends BaseFragment implements Injectable { mentionedUsernames.add(actionableStatus.getAccount().getUsername()); String loggedInUsername = null; AccountEntity activeAccount = accountManager.getActiveAccount(); - if(activeAccount != null) { + if (activeAccount != null) { loggedInUsername = activeAccount.getUsername(); } for (Status.Mention mention : mentions) { mentionedUsernames.add(mention.getUsername()); } mentionedUsernames.remove(loggedInUsername); - Intent intent = new ComposeActivity.IntentBuilder() - .inReplyToId(inReplyToId) - .replyVisibility(replyVisibility) - .contentWarning(contentWarning) - .mentionedUsernames(mentionedUsernames) - .replyingStatusAuthor(actionableStatus.getAccount().getLocalUsername()) - .replyingStatusContent(actionableStatus.getContent().toString()) - .build(getContext()); + ComposeOptions composeOptions = new ComposeOptions(); + composeOptions.setInReplyToId(inReplyToId); + composeOptions.setReplyVisibility(replyVisibility); + composeOptions.setContentWarning(contentWarning); + composeOptions.setMentionedUsernames(mentionedUsernames); + composeOptions.setReplyingStatusAuthor(actionableStatus.getAccount().getLocalUsername()); + composeOptions.setReplyingStatusContent(actionableStatus.getContent().toString()); + + Intent intent = ComposeActivity.startIntent(getContext(), composeOptions); getActivity().startActivity(intent); } @@ -186,12 +188,13 @@ public abstract class SFragment extends BaseFragment implements Injectable { if (status.getReblog() != null) { url = status.getReblog().getUrl(); } - Intent intent = new ComposeActivity.IntentBuilder() - .quoteId(id) - .quoteUrl(url) - .replyVisibility(visibility) - .mentionedUsernames(mentionedUsernames) - .build(getContext()); + ComposeOptions composeOptions = new ComposeOptions(); + composeOptions.setQuoteId(id); + composeOptions.setQuoteUrl(url); + composeOptions.setReplyVisibility(visibility); + composeOptions.setMentionedUsernames(mentionedUsernames); + + Intent intent = ComposeActivity.startIntent(getContext(), composeOptions); startActivity(intent); } @@ -205,7 +208,7 @@ public abstract class SFragment extends BaseFragment implements Injectable { String loggedInAccountId = null; AccountEntity activeAccount = accountManager.getActiveAccount(); - if(activeAccount != null) { + if (activeAccount != null) { loggedInAccountId = activeAccount.getAccountId(); } @@ -238,7 +241,7 @@ public abstract class SFragment extends BaseFragment implements Injectable { Menu menu = popup.getMenu(); MenuItem openAsItem = menu.findItem(R.id.status_open_as); - switch(accounts.size()) { + switch (accounts.size()) { case 0: case 1: openAsItem.setVisible(false); @@ -261,7 +264,8 @@ public abstract class SFragment extends BaseFragment implements Injectable { switch (item.getItemId()) { case R.id.status_share_content: { Status statusToShare = status; - if(statusToShare.getReblog() != null) statusToShare = statusToShare.getReblog(); + if (statusToShare.getReblog() != null) + statusToShare = statusToShare.getReblog(); Intent sendIntent = new Intent(); sendIntent.setAction(Intent.ACTION_SEND); @@ -386,7 +390,8 @@ public abstract class SFragment extends BaseFragment implements Injectable { .observeOn(AndroidSchedulers.mainThread()) .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .subscribe( - deletedStatus -> {}, + deletedStatus -> { + }, error -> { Log.w("SFragment", "error deleting status", error); Toast.makeText(getContext(), R.string.error_generic, Toast.LENGTH_SHORT).show(); @@ -410,22 +415,22 @@ public abstract class SFragment extends BaseFragment implements Injectable { .subscribe(deletedStatus -> { removeItem(position); - if(deletedStatus.isEmpty()) { + if (deletedStatus.isEmpty()) { deletedStatus = status.toDeletedStatus(); } - - ComposeActivity.IntentBuilder intentBuilder = new ComposeActivity.IntentBuilder() - .tootText(deletedStatus.getText()) - .inReplyToId(deletedStatus.getInReplyToId()) - .visibility(deletedStatus.getVisibility()) - .contentWarning(deletedStatus.getSpoilerText()) - .mediaAttachments(deletedStatus.getAttachments()) - .sensitive(deletedStatus.getSensitive()); - if(deletedStatus.getPoll() != null) { - intentBuilder.poll(deletedStatus.getPoll().toNewPoll(deletedStatus.getCreatedAt())); + ComposeOptions composeOptions = new ComposeOptions(); + composeOptions.setTootText(deletedStatus.getText()); + composeOptions.setInReplyToId(deletedStatus.getInReplyToId()); + composeOptions.setVisibility(deletedStatus.getVisibility()); + composeOptions.setContentWarning(deletedStatus.getSpoilerText()); + composeOptions.setMediaAttachments(deletedStatus.getAttachments()); + composeOptions.setSensitive(deletedStatus.getSensitive()); + if (deletedStatus.getPoll() != null) { + composeOptions.setPoll(deletedStatus.getPoll().toNewPoll(deletedStatus.getCreatedAt())); } - Intent intent = intentBuilder.build(getContext()); + Intent intent = ComposeActivity + .startIntent(getContext(), composeOptions); startActivity(intent); }, error -> { @@ -444,22 +449,22 @@ public abstract class SFragment extends BaseFragment implements Injectable { intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); intent.putExtra(MainActivity.STATUS_URL, statusUrl); startActivity(intent); - ((BaseActivity)getActivity()).finishWithoutSlideOutAnimation(); + ((BaseActivity) getActivity()).finishWithoutSlideOutAnimation(); } private void showOpenAsDialog(String statusUrl, CharSequence dialogTitle) { - BaseActivity activity = (BaseActivity)getActivity(); + BaseActivity activity = (BaseActivity) getActivity(); activity.showAccountChooserDialog(dialogTitle, false, account -> openAsAccount(statusUrl, account)); } private void downloadAllMedia(Status status) { 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(); Uri uri = Uri.parse(url); 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); request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename); downloadManager.enqueue(request); @@ -467,8 +472,8 @@ public abstract class SFragment extends BaseFragment implements Injectable { } private void requestDownloadAllMedia(Status status) { - String[] permissions = new String[]{ Manifest.permission.WRITE_EXTERNAL_STORAGE }; - ((BaseActivity)getActivity()).requestPermissions(permissions, (permissions1, grantResults) -> { + String[] permissions = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}; + ((BaseActivity) getActivity()).requestPermissions(permissions, (permissions1, grantResults) -> { if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { downloadAllMedia(status); } else { @@ -516,9 +521,9 @@ public abstract class SFragment extends BaseFragment implements Injectable { @VisibleForTesting public boolean shouldFilterStatus(Status status) { - if(filterRemoveRegex && status.getPoll() != null) { - for(PollOption option: status.getPoll().getOptions()) { - if(filterRemoveRegexMatcher.reset(option.getTitle()).find()) { + if (filterRemoveRegex && status.getPoll() != null) { + for (PollOption option : status.getPoll().getOptions()) { + if (filterRemoveRegexMatcher.reset(option.getTitle()).find()) { return true; } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimePickerFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimePickerFragment.java index e4b20dda5..1349a59c6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimePickerFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimePickerFragment.java @@ -22,7 +22,7 @@ import android.os.Bundle; import androidx.annotation.NonNull; import androidx.fragment.app.DialogFragment; -import com.keylesspalace.tusky.ComposeActivity; +import com.keylesspalace.tusky.components.compose.ComposeActivity; import java.util.Calendar; import java.util.TimeZone; diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/preference/PreferencesFragment.kt index 95b21d3a3..79a5d1af3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/preference/PreferencesFragment.kt @@ -18,9 +18,10 @@ package com.keylesspalace.tusky.fragment.preference import android.os.Bundle import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat -import com.keylesspalace.tusky.ComposeActivity import com.keylesspalace.tusky.PreferencesActivity import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.getNonNullString import com.mikepenz.google_material_typeface_library.GoogleMaterial @@ -120,10 +121,10 @@ class PreferencesFragment : PreferenceFragmentCompat() { val sendCrashReportPreference = requirePreference("sendCrashReport") sendCrashReportPreference.setOnPreferenceClickListener { activity?.let { activity -> - val intent = ComposeActivity.IntentBuilder() - .tootText("@ars42525@odakyu.app $stackTrace".substring(0, 400)) - .contentWarning("Yuito StackTrace") - .build(activity) + val intent = ComposeActivity.startIntent(activity, ComposeOptions( + tootText = "@ars42525@odakyu.app $stackTrace".substring(0, 400), + contentWarning = "Yuito StackTrace" + )) activity.startActivity(intent) sharedPreferences.edit() .remove("stack_trace") diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index fca1776bf..65d096e8d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -43,7 +43,7 @@ interface MastodonApi { fun getLists(): Single> @GET("/api/v1/custom_emojis") - fun getCustomEmojis(): Call> + fun getCustomEmojis(): Single> @GET("api/v1/instance") fun getInstance(): Single @@ -116,14 +116,14 @@ interface MastodonApi { @POST("api/v1/media") fun uploadMedia( @Part file: MultipartBody.Part - ): Call + ): Single @FormUrlEncoded @PUT("api/v1/media/{mediaId}") fun updateMedia( @Path("mediaId") mediaId: String, @Field("description") description: String - ): Call + ): Single @POST("api/v1/statuses") fun createStatus( @@ -238,10 +238,10 @@ interface MastodonApi { @GET("api/v1/accounts/search") fun searchAccounts( - @Query("q") q: String, - @Query("resolve") resolve: Boolean?, - @Query("limit") limit: Int?, - @Query("following") following: Boolean? + @Query("q") query: String, + @Query("resolve") resolve: Boolean? = null, + @Query("limit") limit: Int? = null, + @Query("following") following: Boolean? = null ): Single> @GET("api/v1/accounts/{id}") @@ -318,6 +318,11 @@ interface MastodonApi { @Query("id[]") accountIds: List ): Call> + @GET("api/v1/accounts/{id}/identity_proofs") + fun identityProofs( + @Path("id") accountId: String + ): Call> + @GET("api/v1/blocks") fun blocks( @Query("max_id") maxId: String? diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index b769a98a8..98f2ba0ec 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -23,12 +23,15 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.RemoteInput import androidx.core.content.ContextCompat -import com.keylesspalace.tusky.ComposeActivity import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.service.SendTootService +import com.keylesspalace.tusky.service.TootToSend import com.keylesspalace.tusky.util.NotificationHelper +import com.keylesspalace.tusky.util.randomAlphanumericString import dagger.android.AndroidInjection import javax.inject.Inject @@ -85,19 +88,25 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { val sendIntent = SendTootService.sendTootIntent( context, - text, - spoiler, - visibility, - false, - emptyList(), - emptyList(), - emptyList(), - null, - citedStatusId, - null, - null, - null, - null, null, account, 0) + TootToSend( + text, + spoiler, + visibility.serverString(), + false, + emptyList(), + emptyList(), + emptyList(), + null, + citedStatusId, + null, + null, + null, + null, null, account.id, + 0, + randomAlphanumericString(16), + 0 + ) + ) context.startService(sendIntent) @@ -125,14 +134,14 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { accountManager.setActiveAccount(senderId) - val composeIntent = ComposeActivity.IntentBuilder() - .inReplyToId(citedStatusId) - .replyVisibility(visibility) - .contentWarning(spoiler) - .mentionedUsernames(mentions.toList()) - .replyingStatusAuthor(localAuthorId) - .replyingStatusContent(citedText) - .build(context) + val composeIntent = ComposeActivity.startIntent(context, ComposeOptions( + inReplyToId = citedStatusId, + replyVisibility = visibility, + contentWarning = spoiler, + mentionedUsernames = mentions.toSet(), + replyingStatusAuthor = localAuthorId, + replyingStatusContent = citedText + )) composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt index 5232de701..ef53ce4e8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt @@ -8,7 +8,6 @@ import android.content.ClipData import android.content.ClipDescription import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Build import android.os.IBinder import android.os.Parcelable @@ -19,7 +18,6 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusScheduledEvent -import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.di.Injectable @@ -28,7 +26,6 @@ import com.keylesspalace.tusky.entity.NewStatus import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.SaveTootHelper -import com.keylesspalace.tusky.util.randomAlphanumericString import dagger.android.AndroidInjection import kotlinx.android.parcel.Parcelize import retrofit2.Call @@ -50,7 +47,8 @@ class SendTootService : Service(), Injectable { @Inject lateinit var database: AppDatabase - private lateinit var saveTootHelper: SaveTootHelper + @Inject + lateinit var saveTootHelper: SaveTootHelper private val tootsToSend = ConcurrentHashMap() private val sendCalls = ConcurrentHashMap>() @@ -61,7 +59,6 @@ class SendTootService : Service(), Injectable { override fun onCreate() { AndroidInjection.inject(this) - saveTootHelper = SaveTootHelper(database.tootDao(), this) super.onCreate() } @@ -285,56 +282,19 @@ class SendTootService : Service(), Injectable { @JvmStatic fun sendTootIntent(context: Context, - text: String, - warningText: String, - visibility: Status.Visibility, - sensitive: Boolean, - mediaIds: List, - mediaUris: List, - mediaDescriptions: List, - scheduledAt: String?, - inReplyToId: String?, - poll: NewPoll?, - replyingStatusContent: String?, - replyingStatusAuthorUsername: String?, - savedJsonUrls: String?, - quoteId: String?, - account: AccountEntity, - savedTootUid: Int + tootToSend: TootToSend ): Intent { val intent = Intent(context, SendTootService::class.java) - - val idempotencyKey = randomAlphanumericString(16) - - val tootToSend = TootToSend(text, - warningText, - visibility.serverString(), - sensitive, - mediaIds, - mediaUris.map { it.toString() }, - mediaDescriptions, - scheduledAt, - inReplyToId, - poll, - replyingStatusContent, - replyingStatusAuthorUsername, - savedJsonUrls, - quoteId, - account.id, - savedTootUid, - idempotencyKey, - 0) - intent.putExtra(KEY_TOOT, tootToSend) - if(mediaUris.isNotEmpty()) { + if (tootToSend.mediaUris.isNotEmpty()) { // forward uri permissions intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) val uriClip = ClipData( ClipDescription("Toot Media", arrayOf("image/*", "video/*")), - ClipData.Item(mediaUris[0]) + ClipData.Item(tootToSend.mediaUris[0]) ) - mediaUris + tootToSend.mediaUris .drop(1) .forEach { mediaUri -> uriClip.addItem(ClipData.Item(mediaUri)) @@ -351,21 +311,23 @@ class SendTootService : Service(), Injectable { } @Parcelize -data class TootToSend(val text: String, - val warningText: String, - val visibility: String, - val sensitive: Boolean, - val mediaIds: List, - val mediaUris: List, - val mediaDescriptions: List, - val scheduledAt: String?, - val inReplyToId: String?, - val poll: NewPoll?, - val replyingStatusContent: String?, - val replyingStatusAuthorUsername: String?, - val savedJsonUrls: String?, - val quoteId: String?, - val accountId: Long, - val savedTootUid: Int, - val idempotencyKey: String, - var retries: Int) : Parcelable +data class TootToSend( + val text: String, + val warningText: String, + val visibility: String, + val sensitive: Boolean, + val mediaIds: List, + val mediaUris: List, + val mediaDescriptions: List, + val scheduledAt: String?, + val inReplyToId: String?, + val poll: NewPoll?, + val replyingStatusContent: String?, + val replyingStatusAuthorUsername: String?, + val savedJsonUrls: List?, + val quoteId: String?, + val accountId: Long, + val savedTootUid: Int, + val idempotencyKey: String, + var retries: Int +) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt b/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt new file mode 100644 index 000000000..b60377f52 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt @@ -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 . */ + +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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt b/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt index f064089da..1e170da84 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt @@ -18,7 +18,6 @@ package com.keylesspalace.tusky.service import android.annotation.TargetApi import android.content.Intent import android.service.quicksettings.TileService - import com.keylesspalace.tusky.MainActivity /** diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CountUpDownLatch.java b/app/src/main/java/com/keylesspalace/tusky/util/CountUpDownLatch.java deleted file mode 100644 index 70a084867..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/CountUpDownLatch.java +++ /dev/null @@ -1,51 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.util; - -/** - * This is a synchronization primitive related to {@link java.util.concurrent.CountDownLatch} - * except that it starts at zero and can count upward. - *

- * The intended use case is for waiting for all tasks to be finished when the number of tasks isn't - * known ahead of time, or may change while waiting. - */ -public class CountUpDownLatch { - private int count; - - public CountUpDownLatch() { - this.count = 0; - } - - public synchronized void countDown() { - count--; - notifyAll(); - } - - public synchronized void countUp() { - count++; - notifyAll(); - } - - public synchronized void await() throws InterruptedException { - while (count != 0) { - wait(); - } - } - - public synchronized boolean isEmpty() { - return count == 0; - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java index f6dd82f0a..270f01222 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java @@ -19,6 +19,7 @@ import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.net.Uri; +import android.os.Build; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.method.LinkMovementMethod; @@ -68,8 +69,8 @@ public class LinkHelper { * @param listener to notify about particular spans that are clicked */ public static void setClickableText(TextView view, Spanned content, - @Nullable Status.Mention[] mentions, final LinkListener listener, - boolean removeQuote) { + @Nullable Status.Mention[] mentions, final LinkListener listener, + boolean removeQuote) { SpannableStringBuilder builder = new SpannableStringBuilder(content); URLSpan[] urlSpans = content.getSpans(0, content.length(), URLSpan.class); for (URLSpan span : urlSpans) { @@ -186,6 +187,14 @@ public class LinkHelper { view.setMovementMethod(LinkMovementMethod.getInstance()); } + public static CharSequence createClickableText(String text, String link) { + URLSpan span = new CustomURLSpan(link); + + SpannableStringBuilder clickableText = new SpannableStringBuilder(text); + clickableText.setSpan(span, 0, text.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + return clickableText; + } + /** * Opens a link, depending on the settings, either in the browser or in a custom tab * @@ -229,10 +238,17 @@ public class LinkHelper { public static void openLinkInCustomTab(Uri uri, Context context) { int toolbarColor = ThemeUtils.getColor(context, R.attr.custom_tab_toolbar); - CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder() + CustomTabsIntent.Builder customTabsIntentBuilder = new CustomTabsIntent.Builder() .setToolbarColor(toolbarColor) - .setShowTitle(true) - .build(); + .setShowTitle(true); + + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + customTabsIntentBuilder.setNavigationBarColor( + ThemeUtils.getColor(context, android.R.attr.navigationBarColor) + ); + } + + CustomTabsIntent customTabsIntent = customTabsIntentBuilder.build(); try { customTabsIntent.launchUrl(context, uri); } catch (ActivityNotFoundException e) { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LiveDataUtil.kt b/app/src/main/java/com/keylesspalace/tusky/util/LiveDataUtil.kt new file mode 100644 index 000000000..b0048aefb --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/LiveDataUtil.kt @@ -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 . */ + +package com.keylesspalace.tusky.util + +import androidx.lifecycle.* +import io.reactivex.BackpressureStrategy +import io.reactivex.Observable +import io.reactivex.Single + +inline fun LiveData.map(crossinline mapFunction: (X) -> Y): LiveData = + Transformations.map(this) { input -> mapFunction(input) } + +inline fun LiveData.switchMap( + crossinline switchMapFunction: (X) -> LiveData +): LiveData = Transformations.switchMap(this) { input -> switchMapFunction(input) } + +inline fun LiveData.filter(crossinline predicate: (X) -> Boolean): LiveData { + val liveData = MediatorLiveData() + 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 LiveData.observe(crossinline observer: (T) -> Unit) = + this.observe(lifecycleOwner, Observer { observer(it) }) + + /** + * Just hold a subscription, + */ + fun LiveData.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 combineLiveData(a: LiveData, b: LiveData, combiner: (A, B) -> R): LiveData { + val liveData = MediatorLiveData() + 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 combineOptionalLiveData(a: LiveData, b: LiveData, combiner: (A?, B?) -> R): LiveData { + val liveData = MediatorLiveData() + liveData.addSource(a) { + liveData.value = combiner(a.value, b.value) + } + liveData.addSource(b) { + liveData.value = combiner(a.value, b.value) + } + return liveData +} + +fun Single.toLiveData() = LiveDataReactiveStreams.fromPublisher(this.toFlowable()) +fun Observable.toLiveData( + backpressureStrategy: BackpressureStrategy = BackpressureStrategy.LATEST +) = LiveDataReactiveStreams.fromPublisher(this.toFlowable(BackpressureStrategy.LATEST)) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt index 453b42d2a..43f05e9cb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt @@ -16,13 +16,10 @@ package com.keylesspalace.tusky.util import android.content.ContentResolver -import android.content.Context import android.database.Cursor import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix -import android.media.MediaMetadataRetriever -import android.media.ThumbnailUtils import android.net.Uri import android.provider.OpenableColumns import androidx.annotation.Px @@ -106,26 +103,6 @@ fun getSampledBitmap(contentResolver: ContentResolver, uri: Uri, @Px reqWidth: I } } -fun getImageThumbnail(contentResolver: ContentResolver, uri: Uri, @Px thumbnailSize: Int): Bitmap? { - val source = getSampledBitmap(contentResolver, uri, thumbnailSize, thumbnailSize) ?: return null - return ThumbnailUtils.extractThumbnail(source, thumbnailSize, thumbnailSize, ThumbnailUtils.OPTIONS_RECYCLE_INPUT) -} - -fun getVideoThumbnail(context: Context, uri: Uri, @Px thumbnailSize: Int): Bitmap? { - val retriever = MediaMetadataRetriever() - try { - retriever.setDataSource(context, uri) - } catch (e: IllegalArgumentException) { - Log.w(TAG, e) - return null - } catch (e: SecurityException) { - Log.w(TAG, e) - return null - } - val source = retriever.frameAtTime ?: return null - return ThumbnailUtils.extractThumbnail(source, thumbnailSize, thumbnailSize, ThumbnailUtils.OPTIONS_RECYCLE_INPUT) -} - @Throws(FileNotFoundException::class) fun getImageSquarePixels(contentResolver: ContentResolver, uri: Uri): Long { val input = contentResolver.openInputStream(uri) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java index edc8ab92d..690098309 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java @@ -5,16 +5,18 @@ import android.content.ContentResolver; import android.content.Context; import android.net.Uri; import android.os.AsyncTask; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.FileProvider; import android.text.TextUtils; import android.util.Log; import android.webkit.MimeTypeMap; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.FileProvider; + import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; import com.keylesspalace.tusky.BuildConfig; +import com.keylesspalace.tusky.db.AppDatabase; import com.keylesspalace.tusky.db.TootDao; import com.keylesspalace.tusky.db.TootEntity; import com.keylesspalace.tusky.entity.NewPoll; @@ -27,6 +29,8 @@ import java.util.Date; import java.util.List; import java.util.Locale; +import javax.inject.Inject; + public final class SaveTootHelper { private static final String TAG = "SaveTootHelper"; @@ -35,15 +39,16 @@ public final class SaveTootHelper { private Context context; private Gson gson = new Gson(); - public SaveTootHelper(@NonNull TootDao tootDao, @NonNull Context context) { - this.tootDao = tootDao; + @Inject + public SaveTootHelper(@NonNull AppDatabase appDatabase, @NonNull Context context) { + this.tootDao = appDatabase.tootDao(); this.context = context; } @SuppressLint("StaticFieldLeak") public boolean saveToot(@NonNull String content, @NonNull String contentWarning, - @Nullable String savedJsonUrls, + @Nullable List savedJsonUrls, @NonNull List mediaUris, @NonNull List mediaDescriptions, int savedTootUid, @@ -58,31 +63,25 @@ public final class SaveTootHelper { } // Get any existing file's URIs. - ArrayList existingUris = null; - if (!TextUtils.isEmpty(savedJsonUrls)) { - existingUris = gson.fromJson(savedJsonUrls, - new TypeToken>() { - }.getType()); - } String mediaUrlsSerialized = null; String mediaDescriptionsSerialized = null; if (!ListUtils.isEmpty(mediaUris)) { - List savedList = saveMedia(mediaUris, existingUris); + List savedList = saveMedia(mediaUris, savedJsonUrls); if (!ListUtils.isEmpty(savedList)) { mediaUrlsSerialized = gson.toJson(savedList); - if (!ListUtils.isEmpty(existingUris)) { - deleteMedia(setDifference(existingUris, savedList)); + if (!ListUtils.isEmpty(savedJsonUrls)) { + deleteMedia(setDifference(savedJsonUrls, savedList)); } } else { return false; } mediaDescriptionsSerialized = gson.toJson(mediaDescriptions); - } else if (!ListUtils.isEmpty(existingUris)) { + } else if (!ListUtils.isEmpty(savedJsonUrls)) { /* If there were URIs in the previous draft, but they've now been removed, those files * can be deleted. */ - deleteMedia(existingUris); + deleteMedia(savedJsonUrls); } final TootEntity toot = new TootEntity(savedTootUid, content, mediaUrlsSerialized, mediaDescriptionsSerialized, contentWarning, inReplyToId, @@ -103,15 +102,16 @@ public final class SaveTootHelper { public void deleteDraft(int tootId) { TootEntity item = tootDao.find(tootId); - if(item != null) { + if (item != null) { deleteDraft(item); } } - public void deleteDraft(@NonNull TootEntity item){ + public void deleteDraft(@NonNull TootEntity item) { // Delete any media files associated with the status. ArrayList uris = gson.fromJson(item.getUrls(), - new TypeToken>() {}.getType()); + new TypeToken>() { + }.getType()); if (uris != null) { for (String uriString : uris) { Uri uri = Uri.parse(uriString); @@ -172,7 +172,7 @@ public final class SaveTootHelper { } 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()); } return results; diff --git a/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java index 76dcd4510..dceef0f30 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java @@ -15,6 +15,8 @@ package com.keylesspalace.tusky.util; +import androidx.annotation.NonNull; + import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -24,7 +26,7 @@ public class VersionUtils { private int minor; private int patch; - public VersionUtils(String versionString) { + public VersionUtils(@NonNull String versionString) { String regex = "([0-9]+)\\.([0-9]+)\\.([0-9]+).*"; Pattern pattern = Pattern.compile(regex); Matcher matcher = pattern.matcher(versionString); diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt index b976a5d63..389995ae2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt @@ -51,4 +51,13 @@ inline fun EditText.onTextChanged( callback(s, start, before, count) } }) +} + +inline fun EditText.afterTextChanged( + crossinline callback: (s: Editable) -> Unit) { + addTextChangedListener(object : DefaultTextWatcher() { + override fun afterTextChanged(s: Editable) { + callback(s) + } + }) } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt index adbcaa43e..ad90cacc9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt @@ -6,12 +6,11 @@ import androidx.lifecycle.ViewModel import com.keylesspalace.tusky.appstore.* import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Field +import com.keylesspalace.tusky.entity.IdentityProof import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.Error -import com.keylesspalace.tusky.util.Loading -import com.keylesspalace.tusky.util.Resource -import com.keylesspalace.tusky.util.Success +import com.keylesspalace.tusky.util.* import io.reactivex.disposables.Disposable import retrofit2.Call import retrofit2.Callback @@ -27,6 +26,14 @@ class AccountViewModel @Inject constructor( val accountData = MutableLiveData>() val relationshipData = MutableLiveData>() + private val identityProofData = MutableLiveData>() + + val accountFieldData = combineOptionalLiveData(accountData, identityProofData) { accountRes, identityProofs -> + identityProofs.orEmpty().map { Either.Left(it) } + .plus(accountRes?.data?.fields.orEmpty().map { Either.Right(it) }) + } + + private val callList: MutableList> = mutableListOf() private val disposable: Disposable = eventHub.events .subscribe { event -> @@ -60,6 +67,7 @@ class AccountViewModel @Inject constructor( } override fun onFailure(call: Call, t: Throwable) { + Log.w(TAG, "failed obtaining account", t) accountData.postValue(Error()) isDataLoading = false isRefreshing.postValue(false) @@ -90,6 +98,7 @@ class AccountViewModel @Inject constructor( } override fun onFailure(call: Call>, t: Throwable) { + Log.w(TAG, "failed obtaining relationships", t) relationshipData.postValue(Error()) } }) @@ -98,6 +107,30 @@ class AccountViewModel @Inject constructor( } } + private fun obtainIdentityProof(reload: Boolean = false) { + if (identityProofData.value == null || reload) { + + val call = mastodonApi.identityProofs(accountId) + call.enqueue(object : Callback> { + override fun onResponse(call: Call>, + response: Response>) { + val proofs = response.body() + if (response.isSuccessful && proofs != null ) { + identityProofData.postValue(proofs) + } else { + identityProofData.postValue(emptyList()) + } + } + + override fun onFailure(call: Call>, t: Throwable) { + Log.w(TAG, "failed obtaining identity proofs", t) + } + }) + + callList.add(call) + } + } + fun changeFollowState() { val relationship = relationshipData.value?.data if (relationship?.following == true || relationship?.requested == true) { @@ -227,6 +260,7 @@ class AccountViewModel @Inject constructor( return accountId.let { obtainAccount(isReload) + obtainIdentityProof() if (!isSelf) obtainRelationship(isReload) } diff --git a/app/src/main/java/net/accelf/yuito/QuickTootHelper.java b/app/src/main/java/net/accelf/yuito/QuickTootHelper.java index d1ab5c127..976d4fae2 100644 --- a/app/src/main/java/net/accelf/yuito/QuickTootHelper.java +++ b/app/src/main/java/net/accelf/yuito/QuickTootHelper.java @@ -12,13 +12,13 @@ import android.widget.TextView; import androidx.constraintlayout.widget.ConstraintLayout; -import com.keylesspalace.tusky.ComposeActivity; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.appstore.DrawerFooterClickedEvent; import com.keylesspalace.tusky.appstore.Event; import com.keylesspalace.tusky.appstore.EventHub; import com.keylesspalace.tusky.appstore.PreferenceChangedEvent; import com.keylesspalace.tusky.appstore.QuickReplyEvent; +import com.keylesspalace.tusky.components.compose.ComposeActivity; import com.keylesspalace.tusky.db.AccountEntity; import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.entity.Status; @@ -28,8 +28,9 @@ import java.util.Arrays; import java.util.LinkedHashSet; import java.util.Set; -import static com.keylesspalace.tusky.ComposeActivity.PREF_DEFAULT_TAG; -import static com.keylesspalace.tusky.ComposeActivity.PREF_USE_DEFAULT_TAG; +import static com.keylesspalace.tusky.components.compose.ComposeActivity.CAN_USE_UNLEAKABLE; +import static com.keylesspalace.tusky.components.compose.ComposeActivity.PREF_DEFAULT_TAG; +import static com.keylesspalace.tusky.components.compose.ComposeActivity.PREF_USE_DEFAULT_TAG; public class QuickTootHelper { @@ -74,8 +75,7 @@ public class QuickTootHelper { public void composeButton() { if (tootEditText.getText().length() == 0 && inReplyTo == null) { - Intent composeIntent = new Intent(context, ComposeActivity.class); - context.startActivity(composeIntent); + context.startActivity(getComposeIntent(context, true, false)); } else { startComposeWithQuickComposeData(); } @@ -107,43 +107,45 @@ public class QuickTootHelper { } private void startComposeWithQuickComposeData() { - Intent composeIntent = setupIntentBuilder(false); + Intent intent = getComposeIntent(context, false, false); resetQuickCompose(); - context.startActivity(composeIntent); + context.startActivity(intent); } private void quickToot() { if (tootEditText.getText().toString().length() > 0) { - Intent composeIntent = setupIntentBuilder(true); + Intent intent = getComposeIntent(context, false, true); resetQuickCompose(); - context.startActivity(composeIntent); + context.startActivity(intent); } } - private Intent setupIntentBuilder(boolean tootRightNow) { - ComposeActivity.IntentBuilder intentBuilder = new ComposeActivity.IntentBuilder() - .tootText(tootEditText.getText().toString()) - .visibility(getCurrentVisibility()) - .tootRightNow(tootRightNow); + private Intent getComposeIntent(Context context, boolean onlyVisibility, boolean tootRightNow) { + ComposeActivity.ComposeOptions options = new ComposeActivity.ComposeOptions(); + options.setVisibility(getCurrentVisibility()); + if (onlyVisibility) { + return ComposeActivity.startIntent(context, options); + } + options.setTootText(tootEditText.getText().toString()); + options.setTootRightNow(tootRightNow); - if (inReplyTo == null) { - return intentBuilder.build(context); + if (inReplyTo != null) { + Status.Mention[] mentions = inReplyTo.getMentions(); + Set mentionedUsernames = new LinkedHashSet<>(); + mentionedUsernames.add(inReplyTo.getAccount().getUsername()); + for (Status.Mention mention : mentions) { + mentionedUsernames.add(mention.getUsername()); + } + mentionedUsernames.remove(loggedInUsername); + + options.setInReplyToId(inReplyTo.getId()); + options.setContentWarning(inReplyTo.getSpoilerText()); + options.setMentionedUsernames(mentionedUsernames); + options.setReplyingStatusAuthor(inReplyTo.getAccount().getLocalUsername()); + options.setReplyingStatusContent(inReplyTo.getContent().toString()); } - Status.Mention[] mentions = inReplyTo.getMentions(); - Set mentionedUsernames = new LinkedHashSet<>(); - mentionedUsernames.add(inReplyTo.getAccount().getUsername()); - for (Status.Mention mention : mentions) { - mentionedUsernames.add(mention.getUsername()); - } - mentionedUsernames.remove(loggedInUsername); - - return intentBuilder.inReplyToId(inReplyTo.getId()) - .contentWarning(inReplyTo.getSpoilerText()) - .mentionedUsernames(mentionedUsernames) - .replyingStatusAuthor(inReplyTo.getAccount().getLocalUsername()) - .replyingStatusContent(inReplyTo.getContent().toString()) - .build(context); + return ComposeActivity.startIntent(context, options); } private void resetQuickCompose() { @@ -178,7 +180,7 @@ public class QuickTootHelper { private Status.Visibility getCurrentVisibility() { Status.Visibility visibility = Status.Visibility.byNum(defPrefs.getInt(PREF_CURRENT_VISIBILITY, Status.Visibility.PUBLIC.getNum())); - if (!Arrays.asList(ComposeActivity.CAN_USE_UNLEAKABLE) + if (!Arrays.asList(CAN_USE_UNLEAKABLE) .contains(domain) && visibility == Status.Visibility.UNLEAKABLE) { defPrefs.edit() .putInt(PREF_CURRENT_VISIBILITY, Status.Visibility.PUBLIC.getNum()) @@ -217,8 +219,7 @@ public class QuickTootHelper { visibility = Status.Visibility.PRIVATE; break; case PRIVATE: - if (Arrays.asList(ComposeActivity.CAN_USE_UNLEAKABLE) - .contains(domain)) { + if (Arrays.asList(CAN_USE_UNLEAKABLE).contains(domain)) { visibility = Status.Visibility.UNLEAKABLE; } else { visibility = Status.Visibility.PUBLIC; diff --git a/app/src/main/res/layout/activity_compose.xml b/app/src/main/res/layout/activity_compose.xml index a4be93bd3..e6ca45628 100644 --- a/app/src/main/res/layout/activity_compose.xml +++ b/app/src/main/res/layout/activity_compose.xml @@ -2,7 +2,7 @@ @@ -30,10 +30,9 @@ android:layout_gravity="end" android:padding="8dp" android:text="@string/at_symbol" - android:textStyle="bold" android:textColor="?android:textColorTertiary" android:textSize="?attr/status_text_large" - /> + android:textStyle="bold" /> + android:textStyle="bold" /> - - - - - - - - - - + android:scrollbars="none" /> + @@ -178,7 +170,7 @@ android:paddingBottom="52dp" > @@ -214,7 +206,7 @@ app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> - - + app:srcCompat="@drawable/ic_cw_24dp" /> - @@ -24,6 +26,8 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="8dp" + android:paddingTop="8dp" + android:paddingBottom="8dp" android:layout_weight="1" android:drawablePadding="12dp" android:textColor="?android:attr/textColorSecondary" @@ -32,10 +36,23 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@id/imageView" app:layout_constraintTop_toTopOf="parent" - app:layout_goneMarginBottom="16dp" + app:layout_goneMarginBottom="8dp" tools:drawableStart="@drawable/ic_home_24dp" tools:text="Home" /> + + - diff --git a/app/src/main/res/layout/view_compose_schedule.xml b/app/src/main/res/layout/view_compose_schedule.xml index dde00743d..98ef4f166 100644 --- a/app/src/main/res/layout/view_compose_schedule.xml +++ b/app/src/main/res/layout/view_compose_schedule.xml @@ -5,27 +5,28 @@