diff --git a/app/build.gradle b/app/build.gradle index 5d68379c..d4fff4f3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -92,6 +92,7 @@ project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { } } +ext.lifecycleVersion = "2.1.0" ext.roomVersion = '2.2.1' ext.retrofitVersion = '2.6.0' ext.okhttpVersion = '4.2.2' @@ -114,7 +115,8 @@ dependencies { implementation "androidx.sharetarget:sharetarget:1.0.0-beta01" implementation "androidx.emoji:emoji:1.0.0" implementation "androidx.emoji:emoji-appcompat:1.0.0" - implementation "androidx.lifecycle:lifecycle-extensions:2.1.0" + implementation "androidx.lifecycle:lifecycle-extensions:$lifecycleVersion" + implementation "androidx.lifecycle:lifecycle-reactivestreams:$lifecycleVersion" implementation "androidx.constraintlayout:constraintlayout:1.1.3" implementation "androidx.paging:paging-runtime-ktx:2.1.0" implementation "androidx.viewpager2:viewpager2:1.0.0-rc01" 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 00000000..7845dade --- /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/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 44ba5455..028b7aa4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -96,7 +96,7 @@ { - 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 60b9324b..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java +++ /dev/null @@ -1,2276 +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.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.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.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.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 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"; - // 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; - - @Inject - public MastodonApi mastodonApi; - @Inject - public AppDatabase database; - - private TextView replyTextView; - private TextView replyContentTextView; - private EditTextTyped textEditor; - private LinearLayout mediaPreviewBar; - private View contentWarningBar; - private EditText contentWarningEditor; - 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 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 SaveTootHelper saveTootHelper; - private Gson gson = new Gson(); - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - SharedPreferences 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); - textEditor = findViewById(R.id.composeEditField); - mediaPreviewBar = findViewById(R.id.compose_media_preview_bar); - contentWarningBar = findViewById(R.id.composeContentWarningBar); - contentWarningEditor = findViewById(R.id.composeContentWarningField); - 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(); - - 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())); - - 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 { - // do not do anything when not logged in, activity will be finished in super.onCreate() anyway - return; - } - - composeOptionsView = findViewById(R.id.composeOptionsBottomSheet); - 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, false, false); - - // 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; - } - - /* 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(); - - String[] mentionedUsernames = null; - ArrayList loadedDraftMediaUris = null; - ArrayList loadedDraftMediaDescriptions = null; - ArrayList mediaAttachments = null; - inReplyToId = 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); - - 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); - } - } - - // 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.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(); - } - - 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 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 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) { - 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 = 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), - 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. */ - String contentText = 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); - - } 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.image_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.image_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() { - // 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(); - } - - 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 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; - - 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 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 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 (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); - } - return intent; - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java index 78d80d04..184e6a61 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java @@ -44,6 +44,7 @@ import com.keylesspalace.tusky.appstore.CacheUpdater; import com.keylesspalace.tusky.appstore.EventHub; import com.keylesspalace.tusky.appstore.MainTabsChangedEvent; import com.keylesspalace.tusky.appstore.ProfileEditedEvent; +import com.keylesspalace.tusky.components.compose.ComposeActivity; import com.keylesspalace.tusky.components.conversation.ConversationsRepository; import com.keylesspalace.tusky.components.search.SearchActivity; import com.keylesspalace.tusky.db.AccountEntity; diff --git a/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java b/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java index 8bf6565b..c40639c2 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,29 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd @Override public void click(int position, TootEntity item) { - Intent intent = new ComposeActivity.IntentBuilder() - .savedTootUid(item.getUid()) - .tootText(item.getText()) - .contentWarning(item.getContentWarning()) - .savedJsonUrls(item.getUrls()) - .savedJsonDescriptions(item.getDescriptions()) - .inReplyToId(item.getInReplyToId()) - .replyingStatusAuthor(item.getInReplyToUsername()) - .replyingStatusContent(item.getInReplyToText()) - .visibility(item.getVisibility()) - .poll(item.getPoll()) - .build(this); + Gson gson = new Gson(); + Type stringListType = new TypeToken>() {}.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(), + /*replyVisibility*/null, + item.getVisibility(), + item.getContentWarning(), + item.getInReplyToUsername(), + item.getInReplyToText(), + /*mediaAttachments*/null, + /*scheduledAt*/null, + /*sensitive*/null, + /*poll*/null + ); + Intent intent = ComposeActivity.startIntent(this, composeOptions); startActivity(intent); } diff --git a/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt index a7de7a2b..0bdd14e8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ScheduledTootActivity.kt @@ -11,6 +11,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import com.keylesspalace.tusky.adapter.ScheduledTootAdapter import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.StatusScheduledEvent +import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.network.MastodonApi @@ -135,15 +136,15 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootAdapter.ScheduledToot if (item == null) { return } - val intent = ComposeActivity.IntentBuilder() - .tootText(item.params.text) - .contentWarning(item.params.spoilerText) - .mediaAttachments(item.mediaAttachments) - .inReplyToId(item.params.inReplyToId) - .visibility(item.params.visibility) - .scheduledAt(item.scheduledAt) - .sensitive(item.params.sensitive) - .build(this) + val intent = ComposeActivity.startIntent(this, ComposeActivity.ComposeOptions( + tootText = item.params.text, + contentWarning = item.params.spoilerText, + mediaAttachments = item.mediaAttachments, + inReplyToId = item.params.inReplyToId, + visibility = item.params.visibility, + scheduledAt = item.scheduledAt, + sensitive = item.params.sensitive + )) startActivity(intent) delete(position, item) } diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java index e8b484ca..6bf82461 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/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt new file mode 100644 index 00000000..99d43c65 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -0,0 +1,994 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.compose + +import android.Manifest +import android.app.Activity +import android.app.ProgressDialog +import android.app.TimePickerDialog +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import androidx.preference.PreferenceManager +import android.provider.MediaStore +import android.text.TextUtils +import android.util.Log +import android.view.KeyEvent +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.annotation.ColorInt +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.core.view.inputmethod.InputConnectionCompat +import androidx.core.view.inputmethod.InputContentInfoCompat +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.transition.TransitionManager +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter +import com.keylesspalace.tusky.adapter.EmojiAdapter +import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener +import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog +import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.NewPoll +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog +import com.mikepenz.google_material_typeface_library.GoogleMaterial +import com.mikepenz.iconics.IconicsDrawable +import kotlinx.android.parcel.Parcelize +import kotlinx.android.synthetic.main.activity_compose.* +import java.io.File +import java.io.IOException +import java.util.* +import javax.inject.Inject +import kotlin.collections.ArrayList +import kotlin.math.max +import kotlin.math.min + +class ComposeActivity : BaseActivity(), + ComposeOptionsListener, + ComposeAutoCompleteAdapter.AutocompletionProvider, + OnEmojiSelectedListener, + Injectable, + InputConnectionCompat.OnCommitContentListener, + TimePickerDialog.OnTimeSetListener { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private lateinit var composeOptionsBehavior: BottomSheetBehavior<*> + private lateinit var addMediaBehavior: BottomSheetBehavior<*> + private lateinit var emojiBehavior: BottomSheetBehavior<*> + private lateinit var scheduleBehavior: BottomSheetBehavior<*> + + // this only exists when a status is trying to be sent, but uploads are still occurring + private var finishingUploadDialog: ProgressDialog? = null + private var currentInputContentInfo: InputContentInfoCompat? = null + private var currentFlags: Int = 0 + private var photoUploadUri: Uri? = null + @VisibleForTesting + var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT + + private var composeOptions: ComposeOptions? = null + private lateinit var viewModel: ComposeViewModel + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val preferences = PreferenceManager.getDefaultSharedPreferences(this) + val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT) + if (theme == "black") { + setTheme(R.style.TuskyDialogActivityBlackTheme) + } + setContentView(R.layout.activity_compose) + + setupActionBar() + // do not do anything when not logged in, activity will be finished in super.onCreate() anyway + val activeAccount = accountManager.activeAccount ?: return + + setupAvatar(preferences, activeAccount) + val mediaAdapter = MediaPreviewAdapter( + this, + onAddCaption = { item -> + makeCaptionDialog(item.description, item.uri) { newDescription -> + viewModel.updateDescription(item.localId, newDescription) + } + }, + onRemove = this::removeMediaFromQueue + ) + composeMediaPreviewBar.layoutManager = + LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) + composeMediaPreviewBar.adapter = mediaAdapter + composeMediaPreviewBar.itemAnimator = null + + viewModel = ViewModelProviders.of(this, viewModelFactory)[ComposeViewModel::class.java] + + subscribeToUpdates(mediaAdapter) + setupButtons() + + /* If the composer is started up as a reply to another post, override the "starting" state + * based on what the intent from the reply request passes. */ + if (intent != null) { + this.composeOptions = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA) + viewModel.setup(composeOptions) + setupReplyViews(composeOptions?.replyingStatusAuthor) + val tootText = composeOptions?.tootText + if (!tootText.isNullOrEmpty()) { + composeEditField.setText(tootText) + } + } + + if (!TextUtils.isEmpty(composeOptions?.scheduledAt)) { + composeScheduleView.setDateTime(composeOptions?.scheduledAt) + } + + setupComposeField(viewModel.startingText) + setupContentWarningField(composeOptions?.contentWarning) + setupPollView() + applyShareIntent(intent, savedInstanceState) + + composeEditField.requestFocus() + } + + private fun applyShareIntent(intent: Intent?, savedInstanceState: Bundle?) { + if (intent != null && savedInstanceState == null) { + /* Get incoming images being sent through a share action from another app. Only do this + * when savedInstanceState is null, otherwise both the images from the intent and the + * instance state will be re-queued. */ + val type = intent.type + if (type != null) { + if (type.startsWith("image/") || type.startsWith("video/")) { + val uriList = ArrayList() + 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 setupContentWarningField(startingContentWarning: String?) { + if (startingContentWarning != null) { + composeContentWarningField.setText(startingContentWarning) + } + composeContentWarningField.onTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() } + } + + private fun setupComposeField(startingText: String?) { + composeEditField.setOnCommitContentListener(this) + + composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) } + + composeEditField.setAdapter( + ComposeAutoCompleteAdapter(this)) + composeEditField.setTokenizer(ComposeTokenizer()) + + composeEditField.setText(startingText) + composeEditField.setSelection(composeEditField.length()) + + val mentionColour = composeEditField.linkTextColors.defaultColor + highlightSpans(composeEditField.text, mentionColour) + composeEditField.afterTextChanged { editable -> + highlightSpans(editable, mentionColour) + updateVisibleCharactersLeft() + } + + // work around Android platform bug -> https://issuetracker.google.com/issues/67102093 + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O + || Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1) { + composeEditField.setLayerType(View.LAYER_TYPE_SOFTWARE, null) + } + } + + private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) { + withLifecycleContext { + viewModel.instanceParams.observe { instanceData -> + maximumTootCharacters = instanceData.maxChars + updateVisibleCharactersLeft() + composeScheduleButton.visible(instanceData.supportsScheduled) + } + viewModel.emoji.observe { emoji -> setEmojiList(emoji) } + combineLiveData(viewModel.markMediaAsSensitive, viewModel.showContentWarning) { markSensitive, showContentWarning -> + updateSensitiveMediaToggle(markSensitive, showContentWarning) + showContentWarning(showContentWarning) + }.subscribe() + viewModel.statusVisibility.observe { visibility -> + setStatusVisibility(visibility) + } + viewModel.media.observe { media -> + composeMediaPreviewBar.visible(media.isNotEmpty()) + mediaAdapter.submitList(media) + updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value != false, viewModel.showContentWarning.value != false) + } + viewModel.poll.observe { poll -> + pollPreview.visible(poll != null) + poll?.let(pollPreview::setPoll) + } + viewModel.scheduledAt.observe {scheduledAt -> + if(scheduledAt == null) { + composeScheduleView.resetSchedule() + } else { + composeScheduleView.setDateTime(scheduledAt) + } + updateScheduleButton() + } + combineOptionalLiveData(viewModel.media, viewModel.poll) { media, poll -> + val active = poll == null + && media!!.size != 4 + && media.firstOrNull()?.type != QueuedMedia.Type.VIDEO + enableButton(composeAddMediaButton, active, active) + enablePollButton(media.isNullOrEmpty()) + }.subscribe() + viewModel.uploadError.observe { + displayTransientError(R.string.error_media_upload_sending) + } + } + } + + private fun setupButtons() { + composeOptionsBottomSheet.listener = this + + composeOptionsBehavior = BottomSheetBehavior.from(composeOptionsBottomSheet) + addMediaBehavior = BottomSheetBehavior.from(addMediaBottomSheet) + scheduleBehavior = BottomSheetBehavior.from(composeScheduleView) + emojiBehavior = BottomSheetBehavior.from(emojiView) + + emojiView.layoutManager = GridLayoutManager(this, 3, GridLayoutManager.HORIZONTAL, false) + enableButton(composeEmojiButton, clickable = false, colorActive = false) + + // Setup the interface buttons. + composeTootButton.setOnClickListener { onSendClicked() } + composeAddMediaButton.setOnClickListener { openPickDialog() } + composeToggleVisibilityButton.setOnClickListener { showComposeOptions() } + composeContentWarningButton.setOnClickListener { onContentWarningChanged() } + composeEmojiButton.setOnClickListener { showEmojis() } + composeHideMediaButton.setOnClickListener { toggleHideMedia() } + composeScheduleButton.setOnClickListener { onScheduleClick() } + composeScheduleView.setResetOnClickListener { resetSchedule() } + atButton.setOnClickListener { atButtonClicked() } + hashButton.setOnClickListener { hashButtonClicked() } + + val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + + val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).color(textColor).sizeDp(18) + actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null) + + val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).color(textColor).sizeDp(18) + actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(imageIcon, null, null, null) + + val pollIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).color(textColor).sizeDp(18) + addPollTextActionTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(pollIcon, null, null, null) + + actionPhotoTake.setOnClickListener { initiateCameraApp() } + actionPhotoPick.setOnClickListener { onMediaPick() } + addPollTextActionTextView.setOnClickListener { openPollDialog() } + } + + private fun setupActionBar() { + setSupportActionBar(toolbar) + supportActionBar?.run { + title = null + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + val closeIcon = AppCompatResources.getDrawable(this@ComposeActivity, R.drawable.ic_close_24dp) + ThemeUtils.setDrawableTint(this@ComposeActivity, closeIcon!!, R.attr.compose_close_button_tint) + setHomeAsUpIndicator(closeIcon) + } + + } + + private fun setupAvatar(preferences: SharedPreferences, activeAccount: AccountEntity) { + val actionBarSizeAttr = intArrayOf(R.attr.actionBarSize) + val a = obtainStyledAttributes(null, actionBarSizeAttr) + val avatarSize = a.getDimensionPixelSize(0, 1) + a.recycle() + + val animateAvatars = preferences.getBoolean("animateGifAvatars", false) + loadAvatar( + activeAccount.profilePictureUrl, + composeAvatar, + avatarSize / 8, + animateAvatars + ) + composeAvatar.contentDescription = getString(R.string.compose_active_account_description, + activeAccount.fullName) + } + + private fun replaceTextAtCaret(text: CharSequence) { + // If you select "backward" in an editable, you get SelectionStart > SelectionEnd + val start = composeEditField.selectionStart.coerceAtMost(composeEditField.selectionEnd) + val end = composeEditField.selectionStart.coerceAtLeast(composeEditField.selectionEnd) + composeEditField.text.replace(start, end, text) + + // Set the cursor after the inserted text + composeEditField.setSelection(start + text.length) + } + + private fun atButtonClicked() { + replaceTextAtCaret("@") + } + + private fun hashButtonClicked() { + replaceTextAtCaret("#") + } + + override fun onSaveInstanceState(outState: Bundle) { + if (currentInputContentInfo != null) { + outState.putParcelable("commitContentInputContentInfo", + currentInputContentInfo!!.unwrap() as Parcelable?) + outState.putInt("commitContentFlags", currentFlags) + } + currentInputContentInfo = null + currentFlags = 0 + outState.putParcelable("photoUploadUri", photoUploadUri) + super.onSaveInstanceState(outState) + } + + private fun displayTransientError(@StringRes stringId: Int) { + val bar = Snackbar.make(activityCompose, stringId, Snackbar.LENGTH_LONG) + //necessary so snackbar is shown over everything + bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) + bar.show() + } + + private fun toggleHideMedia() { + this.viewModel.toggleMarkSensitive() + } + + private fun updateSensitiveMediaToggle(markMediaSensitive: Boolean, contentWarningShown: Boolean) { + TransitionManager.beginDelayedTransition(composeHideMediaButton.parent as ViewGroup) + + if (viewModel.media.value.isNullOrEmpty()) { + composeHideMediaButton.hide() + } else { + composeHideMediaButton.show() + @ColorInt val color = if (contentWarningShown) { + composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp) + composeHideMediaButton.isClickable = false + ContextCompat.getColor(this, R.color.compose_media_visible_button_disabled_blue) + + } else { + composeHideMediaButton.isClickable = true + if (markMediaSensitive) { + composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp) + ContextCompat.getColor(this, R.color.tusky_blue) + } else { + composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp) + ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + } + } + composeHideMediaButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + } + } + + private fun updateScheduleButton() { + @ColorInt val color = if (composeScheduleView.time == null) { + ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + } else { + ContextCompat.getColor(this, R.color.tusky_blue) + } + composeScheduleButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + } + + private fun enableButtons(enable: Boolean) { + composeAddMediaButton.isClickable = enable + composeToggleVisibilityButton.isClickable = enable + composeEmojiButton.isClickable = enable + composeHideMediaButton.isClickable = enable + composeScheduleButton.isClickable = enable + composeTootButton.isEnabled = enable + } + + private fun setStatusVisibility(visibility: Status.Visibility) { + composeOptionsBottomSheet.setStatusVisibility(visibility) + composeTootButton.setStatusVisibility(visibility) + + val iconRes = when (visibility) { + Status.Visibility.PUBLIC -> R.drawable.ic_public_24dp + Status.Visibility.PRIVATE -> R.drawable.ic_lock_outline_24dp + Status.Visibility.DIRECT -> R.drawable.ic_email_24dp + Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp + else -> R.drawable.ic_lock_open_24dp + } + val drawable = ThemeUtils.getTintedDrawable(this, iconRes, android.R.attr.textColorTertiary) + composeToggleVisibilityButton.setImageDrawable(drawable) + } + + private fun showComposeOptions() { + if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_HIDDEN || composeOptionsBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { + composeOptionsBehavior.state = BottomSheetBehavior.STATE_EXPANDED + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) + } else { + composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) + } + } + + private fun onScheduleClick() { + if(viewModel.scheduledAt.value == null) { + composeScheduleView.openPickDateDialog() + } else { + showScheduleView() + } + } + + private fun showScheduleView() { + if (scheduleBehavior.state == BottomSheetBehavior.STATE_HIDDEN || scheduleBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { + scheduleBehavior.state = BottomSheetBehavior.STATE_EXPANDED + composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) + } else { + scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) + } + } + + private fun showEmojis() { + emojiView.adapter?.let { + if (it.itemCount == 0) { + val errorMessage = getString(R.string.error_no_custom_emojis, accountManager.activeAccount!!.domain) + Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show() + } else { + if (emojiBehavior.state == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { + emojiBehavior.state = BottomSheetBehavior.STATE_EXPANDED + composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) + } else { + emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) + } + } + } + } + + private fun openPickDialog() { + if (addMediaBehavior.state == BottomSheetBehavior.STATE_HIDDEN || addMediaBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { + addMediaBehavior.state = BottomSheetBehavior.STATE_EXPANDED + composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) + } else { + addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) + } + } + + private fun onMediaPick() { + addMediaBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + //Wait until bottom sheet is not collapsed and show next screen after + if (newState == BottomSheetBehavior.STATE_COLLAPSED) { + addMediaBehavior.removeBottomSheetCallback(this) + if (ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this@ComposeActivity, + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), + PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) + } else { + initiateMediaPicking() + } + } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) {} + } + ) + addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + } + + private fun openPollDialog() { + addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + val instanceParams = viewModel.instanceParams.value!! + showAddPollDialog(this, viewModel.poll.value, instanceParams.pollMaxOptions, + instanceParams.pollMaxLength, viewModel::updatePoll) + } + + private fun setupPollView() { + val margin = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin) + val marginBottom = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom) + + val layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) + layoutParams.setMargins(margin, margin, margin, marginBottom) + pollPreview.layoutParams = layoutParams + + pollPreview.setOnClickListener { + val popup = PopupMenu(this, pollPreview) + val editId = 1 + val removeId = 2 + popup.menu.add(0, editId, 0, R.string.edit_poll) + popup.menu.add(0, removeId, 0, R.string.action_remove) + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + editId -> openPollDialog() + removeId -> removePoll() + } + true + } + popup.show() + } + } + + + private fun removePoll() { + viewModel.poll.value = null + pollPreview.hide() + } + + override fun onVisibilityChanged(visibility: Status.Visibility) { + composeOptionsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + viewModel.statusVisibility.value = visibility + } + + @VisibleForTesting + fun calculateTextLength(): Int { + var offset = 0 + val urlSpans = composeEditField.urls + if (urlSpans != null) { + for (span in urlSpans) { + offset += max(0, span.url.length - MAXIMUM_URL_LENGTH) + } + } + var length = composeEditField.length() - offset + if (viewModel.showContentWarning.value!!) { + length += composeContentWarningField.length() + } + return length + } + + private fun updateVisibleCharactersLeft() { + composeCharactersLeftView.text = String.format(Locale.getDefault(), "%d", maximumTootCharacters - calculateTextLength()) + } + + private fun onContentWarningChanged() { + val showWarning = composeContentWarningBar.isGone + viewModel.showContentWarning.value = showWarning + updateVisibleCharactersLeft() + } + + private fun onSendClicked() { + enableButtons(false) + sendStatus() + } + + /** This is for the fancy keyboards which can insert images and stuff. */ + override fun onCommitContent(inputContentInfo: InputContentInfoCompat, flags: Int, opts: Bundle): Boolean { + try { + currentInputContentInfo?.releasePermission() + } catch (e: Exception) { + Log.e(TAG, "InputContentInfoCompat#releasePermission() failed." + e.message) + } finally { + currentInputContentInfo = null + } + + // Verify the returned content's type is of the correct MIME type + val supported = inputContentInfo.description.hasMimeType("image/*") + + return supported && onCommitContentInternal(inputContentInfo, flags) + } + + private fun onCommitContentInternal(inputContentInfo: InputContentInfoCompat, flags: Int): Boolean { + if (flags and InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION != 0) { + try { + inputContentInfo.requestPermission() + } catch (e: Exception) { + Log.e(TAG, "InputContentInfoCompat#requestPermission() failed." + e.message) + return false + } + } + + // Determine the file size before putting handing it off to be put in the queue. + pickMedia(inputContentInfo.contentUri) + + currentInputContentInfo = inputContentInfo + currentFlags = flags + + return true + } + + private fun sendStatus() { + val contentText = composeEditField.text.toString() + var spoilerText = "" + if (viewModel.showContentWarning.value!!) { + spoilerText = composeContentWarningField.text.toString() + } + val characterCount = calculateTextLength() + if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value!!.isEmpty()) { + composeEditField.error = getString(R.string.error_empty) + enableButtons(true) + } else if (characterCount <= maximumTootCharacters) { + finishingUploadDialog = ProgressDialog.show( + this, getString(R.string.dialog_title_finishing_media_upload), + getString(R.string.dialog_message_uploading_media), true, true) + + viewModel.sendStatus(contentText, spoilerText).observe(this, Observer { + finishingUploadDialog?.dismiss() + finishWithoutSlideOutAnimation() + }) + + } else { + composeEditField.error = getString(R.string.error_compose_character_limit) + enableButtons(true) + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, + 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 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 + ) : Parcelable + + companion object { + private const val TAG = "ComposeActivity" // logging tag + private const val MEDIA_PICK_RESULT = 1 + private const val MEDIA_TAKE_PHOTO_RESULT = 2 + private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1 + + private const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS" + + // Mastodon only counts URLs as this long in terms of status character limits + @VisibleForTesting + const val MAXIMUM_URL_LENGTH = 23 + + @JvmStatic + fun startIntent(context: Context, options: ComposeOptions): Intent { + return Intent(context, ComposeActivity::class.java).apply { + putExtra(COMPOSE_OPTIONS_EXTRA, options) + } + } + + @JvmStatic + fun canHandleMimeType(mimeType: String?): Boolean { + return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType == "text/plain") + } + } +} 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 00000000..05cf31b1 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -0,0 +1,467 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.compose + +import android.net.Uri +import android.util.Log +import androidx.core.net.toUri +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModel +import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter +import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia +import com.keylesspalace.tusky.components.search.SearchType +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.InstanceEntity +import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.service.ServiceClient +import com.keylesspalace.tusky.service.TootToSend +import com.keylesspalace.tusky.util.* +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.disposables.Disposable +import io.reactivex.rxkotlin.Singles +import java.util.* +import javax.inject.Inject + +open class RxAwareViewModel : ViewModel() { + private val disposables = CompositeDisposable() + + fun Disposable.autoDispose() = disposables.add(this) + + override fun onCleared() { + super.onCleared() + disposables.clear() + } +} + +/** + * Throw when trying to add an image when video is already present or the other way around + */ +class VideoOrImageException : Exception() + + +class ComposeViewModel +@Inject constructor( + private val api: MastodonApi, + private val accountManager: AccountManager, + private val mediaUploader: MediaUploader, + private val serviceClient: ServiceClient, + private val saveTootHelper: SaveTootHelper, + private val db: AppDatabase +) : RxAwareViewModel() { + + private var replyingStatusAuthor: String? = null + private var replyingStatusContent: String? = null + internal var startingText: String? = null + private var savedTootUid: Int = 0 + private var startingContentWarning: String? = null + private var inReplyToId: String? = null + private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN + + private val instance: MutableLiveData = 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() + + private val mediaToDisposable = mutableMapOf() + + + init { + + Singles.zip(api.getCustomEmojis(), api.getInstance()) { emojis, instance -> + InstanceEntity( + instance = accountManager.activeAccount?.domain!!, + emojiList = emojis, + maximumTootCharacters = instance.maxTootChars, + maxPollOptions = instance.pollLimits?.maxOptions, + maxPollOptionLength = instance.pollLimits?.maxOptionChars, + version = instance.version + ) + } + .doOnSuccess { + db.instanceDao().insertOrReplace(it) + } + .onErrorResumeNext( + db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) + ) + .subscribe ({ instanceEntity -> + emoji.postValue(instanceEntity.emojiList) + instance.postValue(instanceEntity) + }, { throwable -> + // this can happen on network error when no cached data is available + Log.w(TAG, "error loading instance data", throwable) + }) + .autoDispose() + } + + fun pickMedia(uri: Uri): LiveData> { + // 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 ?: "") + } + + val tootToSend = TootToSend( + content, + spoilerText, + statusVisibility.value!!.serverString(), + mediaUris.isNotEmpty() && markMediaAsSensitive.value!!, + mediaIds, + mediaUris.map { it.toString() }, + mediaDescriptions, + scheduledAt = scheduledAt.value, + inReplyToId = null, + poll = poll.value, + replyingStatusContent = null, + replyingStatusAuthorUsername = null, + savedJsonUrls = null, + accountId = accountManager.activeAccount!!.id, + savedTootUid = 0, + idempotencyKey = randomAlphanumericString(16), + retries = 0 + ) + serviceClient.sendToot(tootToSend) + } + } + + fun updateDescription(localId: Long, description: String): LiveData { + 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 + + + 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) + } + + + composeOptions?.savedTootUid?.let { uid -> + this.savedTootUid = uid + startingText = composeOptions.tootText + } + + val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN + if (tootVisibility.num != Status.Visibility.UNKNOWN.num) { + startingVisibility = tootVisibility + } + val mentionedUsernames = composeOptions?.mentionedUsernames + if (mentionedUsernames != null) { + val builder = StringBuilder() + for (name in mentionedUsernames) { + builder.append('@') + builder.append(name) + builder.append(' ') + } + startingText = builder.toString() + } + + + scheduledAt.value = composeOptions?.scheduledAt + + composeOptions?.sensitive?.let { markMediaAsSensitive.value = it } + + val poll = composeOptions?.poll + if (poll != null && composeOptions.mediaAttachments.isNullOrEmpty()) { + this.poll.value = poll + } + replyingStatusContent = composeOptions?.replyingStatusContent + replyingStatusAuthor = composeOptions?.replyingStatusAuthor + } + + fun updatePoll(newPoll: NewPoll) { + poll.value = newPoll + } + + fun updateScheduledAt(newScheduledAt: String?) { + scheduledAt.value = newScheduledAt + } + + private companion object { + const val TAG = "ComposeViewModel" + } + +} + +fun 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 + +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 364f0e84..880a4167 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 00000000..babb0a39 --- /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 00000000..af41f4bb --- /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 3ed211b2..d0f98bac 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 00000000..e7cc36cb --- /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 73bc21a2..c99df40f 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 dc58f86e..af10b277 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 1ee7e84a..0a5e1c33 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 e82831fd..63e627fc 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 bfb474ee..836d81bd 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 333f41c9..c641f345 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/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 5a4670ca..02d02c9c 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 @@ -195,14 +200,14 @@ 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 d37df70a..e4d743c9 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 bc87d018..0c78349e 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 0797ffb6..1e2adaf0 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 75d6b446..e2c4dfc7 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 996ad505..ff3d0266 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 00000000..66dc2711 --- /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 9015b5f2..5f649554 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 3706bc11..8381d526 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/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index 0a424601..6e716438 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); } @@ -176,7 +178,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(); } @@ -209,7 +211,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); @@ -232,7 +234,8 @@ public abstract class SFragment extends BaseFragment implements Injectable { switch (item.getItemId()) { case R.id.status_share_content: { Status statusToShare = status; - if(statusToShare.getReblog() != null) statusToShare = statusToShare.getReblog(); + if (statusToShare.getReblog() != null) + statusToShare = statusToShare.getReblog(); Intent sendIntent = new Intent(); sendIntent.setAction(Intent.ACTION_SEND); @@ -357,7 +360,8 @@ public abstract class SFragment extends BaseFragment implements Injectable { .observeOn(AndroidSchedulers.mainThread()) .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .subscribe( - deletedStatus -> {}, + deletedStatus -> { + }, error -> { Log.w("SFragment", "error deleting status", error); Toast.makeText(getContext(), R.string.error_generic, Toast.LENGTH_SHORT).show(); @@ -381,22 +385,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 -> { @@ -415,22 +419,22 @@ public abstract class SFragment extends BaseFragment implements Injectable { intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); intent.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); @@ -438,8 +442,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 { @@ -487,9 +491,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 e4b20dda..1349a59c 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/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index fca1776b..2fb3f940 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}") 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 5bf8c76d..a3f717c8 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, account, 0) + TootToSend( + text, + spoiler, + visibility.serverString(), + false, + emptyList(), + emptyList(), + emptyList(), + null, + citedStatusId, + 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 97aac1a8..328265b5 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() } @@ -284,54 +281,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?, - account: AccountEntity, - savedTootUid: Int + tootToSend: TootToSend ): Intent { val intent = Intent(context, SendTootService::class.java) - - val idempotencyKey = randomAlphanumericString(16) - - val tootToSend = TootToSend(text, - warningText, - visibility.serverString(), - sensitive, - mediaIds, - mediaUris.map { it.toString() }, - mediaDescriptions, - scheduledAt, - inReplyToId, - poll, - replyingStatusContent, - replyingStatusAuthorUsername, - savedJsonUrls, - account.id, - savedTootUid, - idempotencyKey, - 0) - intent.putExtra(KEY_TOOT, tootToSend) - if(mediaUris.isNotEmpty()) { + if (tootToSend.mediaUris.isNotEmpty()) { // forward uri permissions intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) val uriClip = ClipData( ClipDescription("Toot Media", arrayOf("image/*", "video/*")), - ClipData.Item(mediaUris[0]) + ClipData.Item(tootToSend.mediaUris[0]) ) - mediaUris + tootToSend.mediaUris .drop(1) .forEach { mediaUri -> uriClip.addItem(ClipData.Item(mediaUri)) @@ -348,20 +310,22 @@ 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 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 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 00000000..b60377f5 --- /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 f064089d..1e170da8 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/LiveDataUtil.kt b/app/src/main/java/com/keylesspalace/tusky/util/LiveDataUtil.kt new file mode 100644 index 00000000..b0048aef --- /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/SaveTootHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java index edc8ab92..69009830 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/ViewExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt index b976a5d6..389995ae 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/res/layout/activity_compose.xml b/app/src/main/res/layout/activity_compose.xml index 410d04db..e7f93639 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" /> + @@ -174,7 +166,7 @@ app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> - - + app:srcCompat="@drawable/ic_cw_24dp" /> - + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" /> \ No newline at end of file diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 29078170..17754eb7 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -487,7 +487,6 @@ التبويقات المبَرمَجة برمجة تبويق صفّر - اضغط هنا لضبط برمجة التبويق. خطأ أثناء البحث عن منشور %s الفواصل المرجعية diff --git a/app/src/main/res/values-bn-rIN/strings.xml b/app/src/main/res/values-bn-rIN/strings.xml index a0e689a4..ac95bd74 100644 --- a/app/src/main/res/values-bn-rIN/strings.xml +++ b/app/src/main/res/values-bn-rIN/strings.xml @@ -508,7 +508,6 @@ নির্ধারিত টুটগুলি নির্ধারিত টুট রিসেট - নির্ধারিত টুট কনফিগার করতে এখানে আলতো চাপুন। টাস্কি দ্বারা চালিত %s পোস্ট অনুসন্ধানে ত্রুটি diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 6027dbe1..3bf95b18 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -526,7 +526,6 @@ Toots programats Programar el toot Reiniciar - Clica aquí per configurar el toot programat. Desenvolupat per Tusky Afegit a les adreces d\'interès Seleccionar la llista diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index afd88935..887a34ea 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -470,7 +470,6 @@ Plánované tooty Naplánovat toot Obnovit - Klepnutím sem nastavíte plánovaný toot. Vždy rozbalovat tooty označené varováními o obsahu Celé slovo Je-li klíčové slovo nebo fráze pouze alfanumerická, bude použita pouze, pokud odpovídá celému slovu diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index f010ac66..45a1ee7c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -451,6 +451,5 @@ Geplante Beiträge Plane Beitrag Zurücksetzen - Drücke hier, um den geplanten Beitrag zu konfigurieren. Dies sind Zeitstempel für Status. Beispiele: \"16s\" oder \"2t\". diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index cacefd8d..c5dc9aa4 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -464,7 +464,6 @@ Planitaj mesaĝoj Plani mesaĝon Restarigi - Frapetu ĉi-tie por agordi la planitan mesaĝon. Funkciigita de Tusky Aldonita al la legosignoj Elekti la liston diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5d2e4887..cea35b62 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -465,7 +465,6 @@ Estados programados Programar estado Reiniciar - Pulsa aquí para configurar un estado programado. Error al buscar el post %s Potenciado por Tusky diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index ccac6900..c4085352 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -311,7 +311,6 @@ %s ez dago ezkutatua - Sakatu hemen programatutako tuta konfiguratzeko. Tut hau ezabatu eta zirriborro berria egin\? Ziur al zaude %s ezabatu nahi duzula\? Domeinu horretatik datorren edukia ez duzu denbora-lerro publikoetan edo jakinarazpenentan ikusiko. Domeinu horretan dituzun jarraitzaileak ezabatuko dira. Domeinu osoa ezkutatu diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index fc4b7ab8..b56ecbe7 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -460,7 +460,6 @@ بوق‌های زمان‌بندی‌شده زمان‌بندی بوق بازنشانی - برای پیکربندی بوق زمان‌بندی‌شده، این‌جا را بزنید. مطمئنید می‌خواهید تمام %s را مسدود کنید؟ محتوای آن دامنه را در هیچ‌یک از خط زمانی‌ها یا در آگاهی‌هایتان نخواهید دید. پیروانتان از آن دامنه، برداشته خواهند شد. هنگامی که کلیدواژه یا عبارت، فقط حروف‌عددی باشد، فقط اگر با تمام واژه مطابق باشد، اعمال خواهد شد عبارت پالایش diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 88be37d9..3bd439b9 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -472,7 +472,6 @@ Pouets planifiés Planifier le pouet Réinitialiser - Appuyez ici pour configurer le pouet planifié. Erreur lors de la récupération du message %s Propulsé par Tusky diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 5197d597..1cb2bb70 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -470,7 +470,6 @@ Időzített tülkök Tülk Időzítése Visszaállítás - Ide nyúlj az időzített tülkök beállításához. Nem találjuk ezt a posztot %s Könyvjelzők diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 4329dbaa..60d705aa 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -476,6 +476,5 @@ Toot programmati Programma un toot RIpristina - Tocca qui configurare i toot programmati. %1$s • %2$s diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml index 90e692c4..a3097527 100644 --- a/app/src/main/res/values-night/styles.xml +++ b/app/src/main/res/values-night/styles.xml @@ -81,7 +81,6 @@ diff --git a/app/src/main/res/values-no-rNB/strings.xml b/app/src/main/res/values-no-rNB/strings.xml index 2b786c31..3f57899b 100644 --- a/app/src/main/res/values-no-rNB/strings.xml +++ b/app/src/main/res/values-no-rNB/strings.xml @@ -507,7 +507,6 @@ Planlagte toots Planlegg toot Tilbakestill - Klikk her for å konfigurere planlagt toot. Det oppsto en feil under henting av %s Drevet av Tusky diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml index b731af9c..9118095f 100644 --- a/app/src/main/res/values-oc/strings.xml +++ b/app/src/main/res/values-oc/strings.xml @@ -477,7 +477,6 @@ Tuts planificats Planificar de tuts Escafar - Tocatz aquí per configurar los tuts planificats. Error en cercant la publicacion %s Propulsat per Tusky diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index df69f180..ab640025 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -484,7 +484,6 @@ Zaplanowane wpisy Zaplanuj wpis Resetuj - Dotknij tutaj, żeby skonfigurować zaplanowany wpis. Napędzane przez Tusky Błąd przy wyszukiwaniu wpisu %s diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 9626a86e..a9ea2f2f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -472,7 +472,6 @@ Agendados Agendar toot Cancelar - Toque aqui para agendar Erro ao pesquisar %s Salvos diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index bb04ff05..ae47c609 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -540,7 +540,6 @@ Отложенные записи Отложить запись Сброс - Нажмите для выбора времени отправки. Ошибка при поиске сообщения / ний Закладки diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index fb4994c0..1d70f3b7 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -521,7 +521,6 @@ Napovedani tuti Ponastavi Napovej tut - Dotaknite se tukaj, da nastavite napovedan tut. Napaka pri iskanju objave %s Poganja ga Tusky diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 28cda626..96b6f814 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -468,7 +468,6 @@ Schemalagda toots Schemalägg toot Återställ - Knacka här för att konfigurera schemalagd toot. Fel vid uppslagning av status %s diff --git a/app/src/main/res/values-sw360dp/toot_button.xml b/app/src/main/res/values-sw380dp/toot_button.xml similarity index 100% rename from app/src/main/res/values-sw360dp/toot_button.xml rename to app/src/main/res/values-sw380dp/toot_button.xml diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index bc7c8d35..77be2810 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -435,7 +435,6 @@ Zamanlanmış iletiler İleti zamanla Sıfırla - Zamanlanmış iletiyi yapılandırmak için buraya dokunun. Bu iletiyi silip yeniden düzenlemek istiyor musun\? Botlar için gösterge göster Tusky tarafından desteklenmektedir diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f06e34df..1ede742d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -159,7 +159,6 @@ Which instance? What\'s happening? - Tap here to configure scheduled toot. Content warning Display name Bio diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 8251dfd6..82660198 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -144,7 +144,6 @@ diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt index c8a06252..a14ebcd1 100644 --- a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt @@ -18,16 +18,21 @@ package com.keylesspalace.tusky import android.text.SpannedString import android.widget.EditText -import com.keylesspalace.tusky.db.AccountEntity -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.InstanceDao +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.components.compose.ComposeViewModel +import com.keylesspalace.tusky.components.compose.DEFAULT_CHARACTER_LIMIT +import com.keylesspalace.tusky.components.compose.MediaUploader +import com.keylesspalace.tusky.db.* +import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Account -import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.network.MastodonApi -import okhttp3.Request -import org.junit.Assert +import com.keylesspalace.tusky.service.ServiceClient +import com.keylesspalace.tusky.util.SaveTootHelper +import com.nhaarman.mockitokotlin2.any +import io.reactivex.Single +import io.reactivex.SingleObserver import org.junit.Assert.* import org.junit.Before import org.junit.Test @@ -35,15 +40,8 @@ import org.junit.runner.RunWith import org.mockito.Mockito.`when` import org.mockito.Mockito.mock import org.robolectric.Robolectric -import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.reactivex.Single -import io.reactivex.SingleObserver import org.robolectric.annotation.Config import org.robolectric.fakes.RoboMenuItem -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response - /** * Created by charlag on 3/7/18. @@ -52,14 +50,15 @@ import retrofit2.Response @Config(application = FakeTuskyApplication::class, sdk = [28]) @RunWith(AndroidJUnit4::class) class ComposeActivityTest { - private lateinit var activity: ComposeActivity private lateinit var accountManagerMock: AccountManager private lateinit var apiMock: MastodonApi + private val instanceDomain = "example.domain" + private val account = AccountEntity( id = 1, - domain = "example.token", + domain = instanceDomain, accessToken = "token", isActive = true, accountId = "1", @@ -83,30 +82,10 @@ class ComposeActivityTest { activity = controller.get() accountManagerMock = mock(AccountManager::class.java) + `when`(accountManagerMock.activeAccount).thenReturn(account) apiMock = mock(MastodonApi::class.java) - `when`(apiMock.getCustomEmojis()).thenReturn(object: Call> { - override fun isExecuted(): Boolean { - return false - } - override fun clone(): Call> { - throw Error("not implemented") - } - override fun isCanceled(): Boolean { - throw Error("not implemented") - } - override fun cancel() { - throw Error("not implemented") - } - override fun execute(): Response> { - throw Error("not implemented") - } - override fun request(): Request { - throw Error("not implemented") - } - - override fun enqueue(callback: Callback>?) {} - }) + `when`(apiMock.getCustomEmojis()).thenReturn(Single.just(emptyList())) `when`(apiMock.getInstance()).thenReturn(object: Single() { override fun subscribeActual(observer: SingleObserver) { val instance = instanceResponseCallback?.invoke() @@ -119,15 +98,27 @@ class ComposeActivityTest { }) val instanceDaoMock = mock(InstanceDao::class.java) + `when`(instanceDaoMock.loadMetadataForInstance(any())).thenReturn( + Single.just(InstanceEntity(instanceDomain, emptyList(),null, null, null, null)) + ) + val dbMock = mock(AppDatabase::class.java) `when`(dbMock.instanceDao()).thenReturn(instanceDaoMock) - activity.mastodonApi = apiMock + val viewModel = ComposeViewModel( + apiMock, + accountManagerMock, + mock(MediaUploader::class.java), + mock(ServiceClient::class.java), + mock(SaveTootHelper::class.java), + dbMock + ) + + val viewModelFactoryMock = mock(ViewModelFactory::class.java) + `when`(viewModelFactoryMock.create(ComposeViewModel::class.java)).thenReturn(viewModel) + activity.accountManager = accountManagerMock - activity.database = dbMock - - `when`(accountManagerMock.activeAccount).thenReturn(account) - + activity.viewModelFactory = viewModelFactoryMock controller.create().start() } @@ -164,7 +155,7 @@ class ComposeActivityTest { fun whenMaximumTootCharsIsNull_defaultLimitIsUsed() { instanceResponseCallback = { getInstanceWithMaximumTootCharacters(null) } setupActivity() - assertEquals(ComposeActivity.STATUS_CHARACTER_LIMIT, activity.maximumTootCharacters) + assertEquals(DEFAULT_CHARACTER_LIMIT, activity.maximumTootCharacters) } @Test @@ -196,7 +187,7 @@ class ComposeActivityTest { val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" val additionalContent = " Check out this @image #search result: " insertSomeTextInContent(shortUrl + additionalContent + url) - Assert.assertEquals(activity.calculateTextLength(), additionalContent.length + shortUrl.length + ComposeActivity.MAXIMUM_URL_LENGTH) + assertEquals(activity.calculateTextLength(), additionalContent.length + shortUrl.length + ComposeActivity.MAXIMUM_URL_LENGTH) } @Test @@ -204,7 +195,7 @@ class ComposeActivityTest { val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" val additionalContent = " Check out this @image #search result: " insertSomeTextInContent(url + additionalContent + url) - Assert.assertEquals(activity.calculateTextLength(), additionalContent.length + (ComposeActivity.MAXIMUM_URL_LENGTH * 2)) + assertEquals(activity.calculateTextLength(), additionalContent.length + (ComposeActivity.MAXIMUM_URL_LENGTH * 2)) } private fun clickUp() { @@ -256,13 +247,5 @@ class ComposeActivityTest { ) } - private fun getSuccessResponseCallbackWithMaximumTootCharacters(maximumTootCharacters: Int?): (Call?, Callback?) -> Unit - { - return { - call: Call?, callback: Callback? -> - if (call != null) { - callback?.onResponse(call, Response.success(getInstanceWithMaximumTootCharacters(maximumTootCharacters))) - } - } - } -} \ No newline at end of file +} +