/* 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.ProgressDialog; 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.graphics.Bitmap; 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.support.annotation.AttrRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.Px; import android.support.annotation.StringRes; import android.support.design.widget.Snackbar; import android.support.v13.view.inputmethod.InputConnectionCompat; import android.support.v13.view.inputmethod.InputContentInfoCompat; import android.support.v4.app.ActivityCompat; import android.support.v4.content.ContextCompat; import android.support.v4.content.FileProvider; import android.support.v7.app.ActionBar; import android.support.v7.app.AlertDialog; import android.support.v7.content.res.AppCompatResources; import android.support.v7.widget.Toolbar; import android.text.Editable; import android.text.InputType; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextUtils; import android.text.TextWatcher; import android.text.style.URLSpan; import android.util.Log; import android.view.MenuItem; import android.view.View; import android.view.Window; import android.view.WindowManager; import android.webkit.MimeTypeMap; import android.widget.Button; import android.widget.EditText; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.PopupMenu; import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; import com.keylesspalace.tusky.adapter.MentionAutoCompleteAdapter; import com.keylesspalace.tusky.db.AccountEntity; import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.db.TootDao; import com.keylesspalace.tusky.db.TootEntity; import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.fragment.ComposeOptionsFragment; import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.ProgressRequestBody; import com.keylesspalace.tusky.util.CountUpDownLatch; import com.keylesspalace.tusky.util.DownsizeImageTask; import com.keylesspalace.tusky.util.IOUtils; import com.keylesspalace.tusky.util.ListUtils; import com.keylesspalace.tusky.util.MediaUtils; import com.keylesspalace.tusky.util.MentionTokenizer; import com.keylesspalace.tusky.util.SpanUtils; import com.keylesspalace.tusky.util.StringUtils; import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.view.EditTextTyped; import com.keylesspalace.tusky.view.ProgressImageView; import com.keylesspalace.tusky.view.RoundedTransformation; import com.squareup.picasso.Picasso; import com.varunest.sparkbutton.helpers.Utils; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collection; import java.util.Date; import java.util.Iterator; import java.util.List; import java.util.Locale; import javax.inject.Inject; import okhttp3.MediaType; import okhttp3.MultipartBody; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; public final class ComposeActivity extends BaseActivity implements ComposeOptionsFragment.Listener, MentionAutoCompleteAdapter.AccountSearchProvider, Injectable { private static final String TAG = "ComposeActivity"; // logging tag private static final int STATUS_CHARACTER_LIMIT = 500; private static final int STATUS_MEDIA_SIZE_LIMIT = 8388608; // 8MiB 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 int COMPOSE_SUCCESS = -1; @Px private static final int THUMBNAIL_SIZE = 128; private static final String SAVED_TOOT_UID_EXTRA = "saved_toot_uid"; private static final String SAVED_TOOT_TEXT_EXTRA = "saved_toot_text"; private static final String SAVED_JSON_URLS_EXTRA = "saved_json_urls"; private static final String IN_REPLY_TO_ID_EXTRA = "in_reply_to_id"; private static final String REPLY_VISIBILITY_EXTRA = "reply_visibilty"; private static final String CONTENT_WARNING_EXTRA = "content_warning"; private static final String MENTIONED_USERNAMES_EXTRA = "netnioned_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 TootDao tootDao = TuskyApplication.getDB().tootDao(); @Inject public MastodonApi mastodonApi; @Inject public AccountManager accountManager; private TextView replyTextView; private TextView replyContentTextView; private EditTextTyped textEditor; private LinearLayout mediaPreviewBar; private View contentWarningBar; private EditText contentWarningEditor; private TextView charactersLeft; private Button floatingBtn; private ImageButton pickButton; private ImageButton visibilityBtn; private ImageButton saveButton; private ImageButton hideMediaToggle; private ProgressBar postProgress; // 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 boolean showMarkSensitive; 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 boolean statusAlreadyInFlight; // to prevent duplicate sends by mashing the send button private InputContentInfoCompat currentInputContentInfo; private int currentFlags; private Uri photoUploadUri; private int savedTootUid = 0; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_compose); replyTextView = findViewById(R.id.reply_tv); replyContentTextView = findViewById(R.id.reply_content_tv); textEditor = findViewById(R.id.compose_edit_field); mediaPreviewBar = findViewById(R.id.compose_media_preview_bar); contentWarningBar = findViewById(R.id.compose_content_warning_bar); contentWarningEditor = findViewById(R.id.field_content_warning); charactersLeft = findViewById(R.id.characters_left); floatingBtn = findViewById(R.id.floating_btn); pickButton = findViewById(R.id.compose_photo_pick); visibilityBtn = findViewById(R.id.action_toggle_visibility); saveButton = findViewById(R.id.compose_save_draft); hideMediaToggle = findViewById(R.id.action_hide_media); postProgress = findViewById(R.id.postProgress); // 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); if (TextUtils.isEmpty(activeAccount.getProfilePictureUrl())) { composeAvatar.setImageResource(R.drawable.avatar_default); } else { Picasso.with(this).load(activeAccount.getProfilePictureUrl()) .transform(new RoundedTransformation(7, 0)) .error(R.drawable.avatar_default) .placeholder(R.drawable.avatar_default) .into(composeAvatar); } composeAvatar.setContentDescription( getString(R.string.compose_active_account_description, activeAccount.getFullName())); } else { // do not do anything when not logged in, activity will be finished in super.onCreate() anyway return; } // Setup the interface buttons. floatingBtn.setOnClickListener(v -> onSendClicked()); floatingBtn.setOnLongClickListener(v -> saveDraft()); pickButton.setOnClickListener(v -> openPickDialog()); visibilityBtn.setOnClickListener(v -> showComposeOptions()); saveButton.setOnClickListener(v -> saveDraft()); hideMediaToggle.setOnClickListener(v -> toggleHideMedia()); //fix a bug with autocomplete and some keyboards int newInputType = textEditor.getInputType() & (textEditor.getInputType() ^ InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE); textEditor.setInputType(newInputType); /* Initialise all the state, or restore it from a previous run, to determine a "starting" * state. */ Status.Visibility startingVisibility = Status.Visibility.UNKNOWN; boolean startingHideText; String startingContentWarning = null; ArrayList savedMediaQueued = null; if (savedInstanceState != null) { showMarkSensitive = savedInstanceState.getBoolean("showMarkSensitive"); 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 { showMarkSensitive = false; statusMarkSensitive = false; 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; inReplyToId = null; if (intent != null) { if (startingVisibility == Status.Visibility.UNKNOWN) { Status.Visibility replyVisibility = Status.Visibility.byNum( intent.getIntExtra(REPLY_VISIBILITY_EXTRA, Status.Visibility.UNKNOWN.getNum())); if (replyVisibility != Status.Visibility.UNKNOWN) { startingVisibility = replyVisibility; } else { SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); startingVisibility = Status.Visibility.byString( preferences.getString("defaultPostPrivacy", Status.Visibility.PUBLIC.serverString())); } } 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; } } // If come from SavedTootActivity String savedTootText = intent.getStringExtra(SAVED_TOOT_TEXT_EXTRA); if (!TextUtils.isEmpty(savedTootText)) { textEditor.append(savedTootText); } String savedJsonUrls = intent.getStringExtra(SAVED_JSON_URLS_EXTRA); if (!TextUtils.isEmpty(savedJsonUrls)) { // try to redo a list of media loadedDraftMediaUris = new Gson().fromJson(savedJsonUrls, new TypeToken>() { }.getType()); } int savedTootUid = intent.getIntExtra(SAVED_TOOT_UID_EXTRA, 0); if (savedTootUid != 0) { this.savedTootUid = savedTootUid; } 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)); replyTextView.setOnClickListener(v -> { if (replyContentTextView.getVisibility() != View.VISIBLE) { replyContentTextView.setVisibility(View.VISIBLE); } else { replyContentTextView.setVisibility(View.GONE); } }); } if (intent.hasExtra(REPLYING_STATUS_CONTENT_EXTRA)) { replyContentTextView.setText(intent.getStringExtra(REPLYING_STATUS_CONTENT_EXTRA)); } } // After the starting state is finalised, the interface can be set to reflect this state. setStatusVisibility(startingVisibility); postProgress.setVisibility(View.INVISIBLE); updateHideMediaToggleColor(); updateVisibleCharactersLeft(); // Setup the main text field. setEditTextMimeTypes(); // new String[] { "image/gif", "image/webp" } final int mentionColour = ThemeUtils.getColor(this, R.attr.compose_mention_color); SpanUtils.highlightSpans(textEditor.getText(), mentionColour); textEditor.addTextChangedListener(new TextWatcher() { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { updateVisibleCharactersLeft(); } @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } @Override public void afterTextChanged(Editable editable) { SpanUtils.highlightSpans(editable, mentionColour); } }); textEditor.setAdapter( new MentionAutoCompleteAdapter(this, R.layout.item_autocomplete, this)); textEditor.setTokenizer(new MentionTokenizer()); // 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(' '); } textEditor.setText(builder); textEditor.setSelection(textEditor.length()); } // 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(); statusAlreadyInFlight = false; // These can only be added after everything affected by the media queue is initialized. if (!ListUtils.isEmpty(loadedDraftMediaUris)) { for (String uriString : loadedDraftMediaUris) { Uri uri = Uri.parse(uriString); long mediaSize = MediaUtils.getMediaSize(getContentResolver(), uri); pickMedia(uri, mediaSize); } } else if (savedMediaQueued != null) { for (SavedQueuedMedia item : savedMediaQueued) { Bitmap preview = MediaUtils.getImageThumbnail(getContentResolver(), item.uri, THUMBNAIL_SIZE); addMediaToQueue(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/")) { 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 = MediaUtils.getMediaSize(getContentResolver(), uri); pickMedia(uri, mediaSize); } } else if (type.equals("text/plain")) { String action = intent.getAction(); if (action != null && action.equals(Intent.ACTION_SEND)) { String text = intent.getStringExtra(Intent.EXTRA_TEXT); if (text != 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, text, 0, text.length()); } } } } } textEditor.requestFocus(); } @Override protected void onSaveInstanceState(Bundle outState) { ArrayList savedMediaQueued = new ArrayList<>(); for (QueuedMedia item : mediaQueued) { savedMediaQueued.add(new SavedQueuedMedia(item.type, item.uri, item.mediaSize, item.readyStage, item.description)); } outState.putParcelableArrayList("savedMediaQueued", savedMediaQueued); outState.putBoolean("showMarkSensitive", showMarkSensitive); 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); bar.show(); } private void displayTransientError(@StringRes int stringId) { Snackbar.make(findViewById(R.id.activity_compose), stringId, Snackbar.LENGTH_LONG).show(); } private void toggleHideMedia() { statusMarkSensitive = !statusMarkSensitive; updateHideMediaToggleColor(); } private void updateHideMediaToggleColor() { @AttrRes int attribute; if (statusMarkSensitive) { attribute = R.attr.compose_hide_media_button_selected_color; } else { attribute = R.attr.compose_hide_media_button_color; } ThemeUtils.setDrawableTint(this, hideMediaToggle.getDrawable(), attribute); } private void disableButtons() { pickButton.setClickable(false); visibilityBtn.setClickable(false); saveButton.setClickable(false); hideMediaToggle.setClickable(false); floatingBtn.setEnabled(false); } private void enableButtons() { pickButton.setClickable(true); visibilityBtn.setClickable(true); saveButton.setClickable(true); hideMediaToggle.setClickable(true); floatingBtn.setEnabled(true); } private boolean saveDraft() { String contentWarning = null; if (statusHideText) { contentWarning = contentWarningEditor.getText().toString(); } Editable textToSave = textEditor.getEditableText(); /* Discard any upload URLs embedded in the text because they'll be re-uploaded when * the draft is loaded and replaced with new URLs. */ if (mediaQueued != null) { for (QueuedMedia item : mediaQueued) { textToSave = removeUrlFromEditable(textToSave, item.uploadUrl); } } boolean didSaveSuccessfully = saveTheToot(textToSave.toString(), contentWarning); if (didSaveSuccessfully) { Toast.makeText(ComposeActivity.this, R.string.action_save_one_toot, Toast.LENGTH_SHORT) .show(); } return didSaveSuccessfully; } private static boolean copyToFile(ContentResolver contentResolver, Uri uri, File file) { InputStream from; FileOutputStream to; try { from = contentResolver.openInputStream(uri); to = new FileOutputStream(file); } catch (FileNotFoundException e) { return false; } if (from == null) { return false; } byte[] chunk = new byte[16384]; try { while (true) { int bytes = from.read(chunk, 0, chunk.length); if (bytes < 0) { break; } to.write(chunk, 0, bytes); } } catch (IOException e) { return false; } IOUtils.closeQuietly(from); IOUtils.closeQuietly(to); return true; } @Nullable private List saveMedia(@Nullable ArrayList existingUris) { File imageDirectory = getExternalFilesDir(Environment.DIRECTORY_PICTURES); File videoDirectory = getExternalFilesDir(Environment.DIRECTORY_MOVIES); if (imageDirectory == null || !(imageDirectory.exists() || imageDirectory.mkdirs())) { Log.e(TAG, "Image directory is not created."); return null; } if (videoDirectory == null || !(videoDirectory.exists() || videoDirectory.mkdirs())) { Log.e(TAG, "Video directory is not created."); return null; } ContentResolver contentResolver = getContentResolver(); ArrayList filesSoFar = new ArrayList<>(); ArrayList results = new ArrayList<>(); for (QueuedMedia item : mediaQueued) { /* If the media was already saved in a previous draft, there's no need to save another * copy, just add the existing URI to the results. */ if (existingUris != null) { String uri = item.uri.toString(); int index = existingUris.indexOf(uri); if (index != -1) { results.add(uri); continue; } } // Otherwise, save the media. File directory; switch (item.type) { default: case IMAGE: directory = imageDirectory; break; case VIDEO: directory = videoDirectory; break; } String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) .format(new Date()); String mimeType = contentResolver.getType(item.uri); MimeTypeMap map = MimeTypeMap.getSingleton(); String fileExtension = map.getExtensionFromMimeType(mimeType); String filename = String.format("Tusky_Draft_Media_%s.%s", timeStamp, fileExtension); File file = new File(directory, filename); filesSoFar.add(file); boolean copied = copyToFile(contentResolver, item.uri, file); if (!copied) { /* If any media files were created in prior iterations, delete those before * returning. */ for (File earlierFile : filesSoFar) { boolean deleted = earlierFile.delete(); if (!deleted) { Log.i(TAG, "Could not delete the file " + earlierFile.toString()); } } return null; } Uri uri = FileProvider.getUriForFile(this, "com.keylesspalace.tusky.fileprovider", file); results.add(uri.toString()); } return results; } private void deleteMedia(List mediaUris) { for (String uriString : mediaUris) { Uri uri = Uri.parse(uriString); if (getContentResolver().delete(uri, null, null) == 0) { Log.e(TAG, String.format("Did not delete file %s.", uriString)); } } } /** * A∖B={x∈A|x∉B} * * @return all elements of set A that are not in set B. */ private static List setDifference(List a, List b) { List c = new ArrayList<>(); for (String s : a) { if (!b.contains(s)) { c.add(s); } } return c; } @SuppressLint("StaticFieldLeak") private boolean saveTheToot(String s, @Nullable String contentWarning) { if (TextUtils.isEmpty(s)) { return false; } // Get any existing file's URIs. ArrayList existingUris = null; String savedJsonUrls = getIntent().getStringExtra("saved_json_urls"); if (!TextUtils.isEmpty(savedJsonUrls)) { existingUris = new Gson().fromJson(savedJsonUrls, new TypeToken>() { }.getType()); } String mediaUrlsSerialized = null; if (!ListUtils.isEmpty(mediaQueued)) { List savedList = saveMedia(existingUris); if (!ListUtils.isEmpty(savedList)) { mediaUrlsSerialized = new Gson().toJson(savedList); if (!ListUtils.isEmpty(existingUris)) { deleteMedia(setDifference(existingUris, savedList)); } } else { return false; } } else if (!ListUtils.isEmpty(existingUris)) { /* If there were URIs in the previous draft, but they've now been removed, those files * can be deleted. */ deleteMedia(existingUris); } final TootEntity toot = new TootEntity(savedTootUid, s, mediaUrlsSerialized, contentWarning, inReplyToId, getIntent().getStringExtra(REPLYING_STATUS_CONTENT_EXTRA), getIntent().getStringExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA), statusVisibility); new AsyncTask() { @Override protected Void doInBackground(Void... params) { tootDao.insertOrReplace(toot); return null; } }.execute(); return true; } private void addLockToSendButton() { floatingBtn.setText(R.string.action_send); Drawable lock = AppCompatResources.getDrawable(this, R.drawable.send_private); if (lock != null) { lock.setBounds(0, 0, lock.getIntrinsicWidth(), lock.getIntrinsicHeight()); floatingBtn.setCompoundDrawables(null, null, lock, null); } } private void setStatusVisibility(Status.Visibility visibility) { statusVisibility = visibility; switch (visibility) { case PUBLIC: { floatingBtn.setText(R.string.action_send_public); floatingBtn.setCompoundDrawables(null, null, null, null); Drawable globe = AppCompatResources.getDrawable(this, R.drawable.ic_public_24dp); if (globe != null) { visibilityBtn.setImageDrawable(globe); } break; } case PRIVATE: { addLockToSendButton(); Drawable lock = AppCompatResources.getDrawable(this, R.drawable.ic_lock_outline_24dp); if (lock != null) { visibilityBtn.setImageDrawable(lock); } break; } case DIRECT: { addLockToSendButton(); Drawable envelope = AppCompatResources.getDrawable(this, R.drawable.ic_email_24dp); if (envelope != null) { visibilityBtn.setImageDrawable(envelope); } break; } case UNLISTED: default: { floatingBtn.setText(R.string.action_send); floatingBtn.setCompoundDrawables(null, null, null, null); Drawable openLock = AppCompatResources.getDrawable(this, R.drawable.ic_lock_open_24dp); if (openLock != null) { visibilityBtn.setImageDrawable(openLock); } break; } } } private void showComposeOptions() { ComposeOptionsFragment fragment = ComposeOptionsFragment.newInstance( statusVisibility, statusHideText); fragment.show(getSupportFragmentManager(), null); } @Override public void onVisibilityChanged(Status.Visibility visibility) { setStatusVisibility(visibility); } private void updateVisibleCharactersLeft() { int left = STATUS_CHARACTER_LIMIT - textEditor.length(); if (statusHideText) { left -= contentWarningEditor.length(); } charactersLeft.setText(String.format(Locale.getDefault(), "%d", left)); } public void onContentWarningChanged(boolean hideText) { showContentWarning(hideText); updateVisibleCharactersLeft(); } private void setStateToReadying() { statusAlreadyInFlight = true; disableButtons(); postProgress.setVisibility(View.VISIBLE); } private void setStateToNotReadying() { postProgress.setVisibility(View.INVISIBLE); statusAlreadyInFlight = false; enableButtons(); } private void onSendClicked() { if (statusAlreadyInFlight) { return; } setStateToReadying(); readyStatus(statusVisibility, statusMarkSensitive); } private void setEditTextMimeTypes() { final String[] mimeTypes = new String[]{"image/*"}; textEditor.setMimeTypes(mimeTypes, (inputContentInfo, flags, opts) -> ComposeActivity.this.onCommitContent(inputContentInfo, flags, mimeTypes)); } private boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, String[] mimeTypes) { 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 actually in the list of MIME types requested. boolean supported = false; for (final String mimeType : mimeTypes) { if (inputContentInfo.getDescription().hasMimeType(mimeType)) { supported = true; break; } } 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 = MediaUtils.MEDIA_SIZE_UNKNOWN; } pickMedia(uri, mediaSize); currentInputContentInfo = inputContentInfo; currentFlags = flags; return true; } private void sendStatus(String content, Status.Visibility visibility, boolean sensitive, String spoilerText) { ArrayList mediaIds = new ArrayList<>(); for (QueuedMedia item : mediaQueued) { mediaIds.add(item.id); } Callback callback = new Callback() { @Override public void onResponse(@NonNull Call call, @NonNull Response response) { if (response.isSuccessful()) { onSendSuccess(); } else { onSendFailure(response); } } @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { onSendFailure(null); } }; mastodonApi.createStatus(content, inReplyToId, spoilerText, visibility.serverString(), sensitive, mediaIds).enqueue(callback); } private void onSendSuccess() { // If the status was loaded from a draft, delete the draft and associated media files. if (savedTootUid != 0) { tootDao.delete(savedTootUid); for (QueuedMedia item : mediaQueued) { try { if (getContentResolver().delete(item.uri, null, null) == 0) { Log.e(TAG, String.format("Did not delete file %s.", item.uri.toString())); } } catch (SecurityException e) { Log.e(TAG, String.format("Did not delete file %s.", item.uri.toString()), e); } } } Snackbar bar = Snackbar.make(findViewById(R.id.activity_compose), getString(R.string.confirmation_send), Snackbar.LENGTH_SHORT); bar.show(); setResult(COMPOSE_SUCCESS); finish(); } private void onSendFailure(@Nullable Response response) { setStateToNotReadying(); if (response != null && inReplyToId != null && response.code() == 404) { new AlertDialog.Builder(this) .setMessage(R.string.dialog_reply_not_found) .setPositiveButton(android.R.string.ok, (dialog, which) -> { inReplyToId = null; replyContentTextView.setVisibility(View.GONE); replyTextView.setVisibility(View.GONE); }) .setNegativeButton(android.R.string.cancel, null) .show(); } else { textEditor.setError(getString(R.string.error_generic)); } } private void readyStatus(final Status.Visibility visibility, final boolean sensitive) { 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(); setStateToNotReadying(); 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. This has to be delayed until after all * uploads finish because their links are added when the upload succeeds and that affects * whether the limit is met or not. */ String contentText = textEditor.getText().toString(); String spoilerText = ""; if (statusHideText) { spoilerText = contentWarningEditor.getText().toString(); } int characterCount = contentText.length() + spoilerText.length(); if (characterCount > 0 && characterCount <= STATUS_CHARACTER_LIMIT) { sendStatus(contentText, visibility, sensitive, spoilerText); } else if (characterCount <= 0) { textEditor.setError(getString(R.string.error_empty)); setStateToNotReadying(); } else { textEditor.setError(getString(R.string.error_compose_character_limit)); setStateToNotReadying(); } } 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)); setStateToNotReadying(); } private void openPickDialog() { final int CHOICE_TAKE = 0; final int CHOICE_PICK = 1; CharSequence[] choices = new CharSequence[2]; choices[CHOICE_TAKE] = getString(R.string.action_photo_take); choices[CHOICE_PICK] = getString(R.string.action_photo_pick); DialogInterface.OnClickListener listener = (dialog, which) -> { switch (which) { case CHOICE_TAKE: { initiateCameraApp(); break; } case CHOICE_PICK: { onMediaPick(); break; } } }; AlertDialog dialog = new AlertDialog.Builder(this) .setItems(choices, listener) .create(); dialog.show(); } private void onMediaPick() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE); } else { initiateMediaPicking(); } } @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 timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()); String imageFileName = "Tusky_" + timeStamp + "_"; File storageDir = getExternalFilesDir(Environment.DIRECTORY_PICTURES); return File.createTempFile( imageFileName, /* prefix */ ".jpg", /* suffix */ storageDir /* directory */ ); } private void initiateCameraApp() { // 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, "com.keylesspalace.tusky.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); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { intent.setType("image/* video/*"); } else { String[] mimeTypes = new String[]{"image/*", "video/*"}; intent.setType("*/*"); intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); } startActivityForResult(intent, MEDIA_PICK_RESULT); } private void enableMediaButtons() { pickButton.setEnabled(true); ThemeUtils.setDrawableTint(this, pickButton.getDrawable(), R.attr.compose_media_button_tint); } private void disableMediaButtons() { pickButton.setEnabled(false); ThemeUtils.setDrawableTint(this, pickButton.getDrawable(), R.attr.compose_media_button_disabled_tint); } private void addMediaToQueue(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.readyStage = readyStage; ImageView view = item.preview; Resources resources = getResources(); int side = resources.getDimensionPixelSize(R.dimen.compose_media_preview_side); 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(side, side); layoutParams.setMargins(margin, 0, margin, marginBottom); view.setLayoutParams(layoutParams); view.setScaleType(ImageView.ScaleType.CENTER_CROP); view.setImageBitmap(preview); view.setOnClickListener(v -> onMediaClick(item, v)); view.setContentDescription(getString(R.string.action_delete)); mediaPreviewBar.addView(view); mediaQueued.add(item); int queuedCount = mediaQueued.size(); if (queuedCount == 1) { /* The media preview bar is actually not inset in the EditText, it just overlays it and * is aligned to the bottom. But, so that text doesn't get hidden under it, extra * padding is added at the bottom of the EditText. */ int totalHeight = side + margin + marginBottom; textEditor.setPadding(textEditor.getPaddingLeft(), textEditor.getPaddingTop(), textEditor.getPaddingRight(), totalHeight); // If there's one video in the queue it is full, so disable the button to queue more. if (item.type == QueuedMedia.Type.VIDEO) { disableMediaButtons(); } } else if (queuedCount >= Status.MAX_MEDIA_ATTACHMENTS) { // Limit the total media attachments, also. disableMediaButtons(); } if (queuedCount >= 1) { showMarkSensitive(true); } if (item.readyStage != QueuedMedia.ReadyStage.UPLOADED) { waitForMediaLatch.countUp(); if (mediaSize > STATUS_MEDIA_SIZE_LIMIT && type == QueuedMedia.Type.IMAGE) { downsizeMedia(item); } else { uploadMedia(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_media); 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); Picasso.with(this) .load(item.uri) .into(imageView); 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(R.string.hint_describe_for_visually_impaired); dialogLayout.addView(input); ((LinearLayout.LayoutParams) input.getLayoutParams()).setMargins(margin, margin, margin, margin); input.setLines(1); input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES); input.setText(item.description); DialogInterface.OnClickListener okListener = (dialog, which) -> { 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(); dialog.dismiss(); } else { showFailedCaptionMessage(); } } @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { showFailedCaptionMessage(); } }); }; 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) { //noinspection ConstantConditions 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) { showMarkSensitive(false); /* If there are no image previews to show, the extra padding that was added to the * EditText can be removed so there isn't unnecessary empty space. */ textEditor.setPadding(textEditor.getPaddingLeft(), textEditor.getPaddingTop(), textEditor.getPaddingRight(), 0); } textEditor.setText(removeUrlFromEditable(textEditor.getEditableText(), item.uploadUrl)); enableMediaButtons(); cancelReadyingMedia(item); } private void removeAllMediaFromQueue() { for (Iterator it = mediaQueued.iterator(); it.hasNext(); ) { QueuedMedia item = it.next(); it.remove(); removeMediaFromQueue(item); } } private static Editable removeUrlFromEditable(Editable editable, @Nullable URLSpan urlSpan) { if (urlSpan == null) { return editable; } SpannableStringBuilder builder = new SpannableStringBuilder(editable); int start = builder.getSpanStart(urlSpan); int end = builder.getSpanEnd(urlSpan); if (start != -1 && end != -1) { builder.delete(start, end); } return builder; } private void downsizeMedia(final QueuedMedia item) { item.readyStage = QueuedMedia.ReadyStage.DOWNSIZING; new DownsizeImageTask(STATUS_MEDIA_SIZE_LIMIT, getContentResolver(), new DownsizeImageTask.Listener() { @Override public void onSuccess(List contentList) { item.content = contentList.get(0); uploadMedia(item); } @Override public void onFailure() { onMediaDownsizeFailure(item); } }).execute(item.uri); } private void onMediaDownsizeFailure(QueuedMedia item) { displayTransientError(R.string.error_media_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); byte[] content = item.content; if (content == null) { InputStream stream; try { stream = getContentResolver().openInputStream(item.uri); } catch (FileNotFoundException e) { Log.d(TAG, Log.getStackTraceString(e)); return; } content = MediaUtils.inputStreamGetBytes(stream); IOUtils.closeQuietly(stream); if (content == null) { return; } } if (mimeType == null) mimeType = "multipart/form-data"; item.preview.setProgress(0); ProgressRequestBody fileBody = new ProgressRequestBody(content, MediaType.parse(mimeType), false, // If request body logging is enabled, pass true 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()); } 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()); } }); } private void onUploadSuccess(final QueuedMedia item, Attachment media) { item.id = media.getId(); item.preview.setProgress(-1); item.readyStage = QueuedMedia.ReadyStage.UPLOADED; /* Add the upload URL to the text field. Also, keep a reference to the span so if the user * chooses to remove the media, the URL is also automatically removed. */ item.uploadUrl = new URLSpan(media.getTextUrl()); int end = 1 + media.getTextUrl().length(); SpannableStringBuilder builder = new SpannableStringBuilder(); builder.append(' '); builder.append(media.getTextUrl()); builder.setSpan(item.uploadUrl, 1, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); int cursorStart = textEditor.getSelectionStart(); int cursorEnd = textEditor.getSelectionEnd(); textEditor.append(builder); textEditor.setSelection(cursorStart, cursorEnd); 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 data) { super.onActivityResult(requestCode, resultCode, data); if (resultCode == RESULT_OK && requestCode == MEDIA_PICK_RESULT && data != null) { Uri uri = data.getData(); long mediaSize = MediaUtils.getMediaSize(getContentResolver(), uri); pickMedia(uri, mediaSize); } else if (resultCode == RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) { long mediaSize = MediaUtils.getMediaSize(getContentResolver(), photoUploadUri); pickMedia(photoUploadUri, mediaSize); } } private void pickMedia(Uri uri, long mediaSize) { if (mediaSize == MediaUtils.MEDIA_SIZE_UNKNOWN) { displayTransientError(R.string.error_media_upload_opening); return; } ContentResolver contentResolver = getContentResolver(); String mimeType = contentResolver.getType(uri); if (mimeType != null) { String topLevelType = mimeType.substring(0, mimeType.indexOf('/')); switch (topLevelType) { case "video": { if (mediaSize > STATUS_MEDIA_SIZE_LIMIT) { displayTransientError(R.string.error_media_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 = MediaUtils.getVideoThumbnail(this, uri, THUMBNAIL_SIZE); if (bitmap != null) { addMediaToQueue(QueuedMedia.Type.VIDEO, bitmap, uri, mediaSize, null, null); } else { displayTransientError(R.string.error_media_upload_opening); } break; } case "image": { Bitmap bitmap = MediaUtils.getImageThumbnail(contentResolver, uri, THUMBNAIL_SIZE); if (bitmap != null) { addMediaToQueue(QueuedMedia.Type.IMAGE, bitmap, uri, mediaSize, null, null); } 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 showMarkSensitive(boolean show) { showMarkSensitive = show; if (!showMarkSensitive) { statusMarkSensitive = false; ThemeUtils.setDrawableTint(this, hideMediaToggle.getDrawable(), R.attr.compose_hide_media_button_color); } if (show) { hideMediaToggle.setVisibility(View.VISIBLE); } else { hideMediaToggle.setVisibility(View.GONE); } } private void showContentWarning(boolean show) { statusHideText = show; if (show) { contentWarningBar.setVisibility(View.VISIBLE); } else { contentWarningBar.setVisibility(View.GONE); } } @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. handleCloseButton(); } private void handleCloseButton() { if (!TextUtils.isEmpty(textEditor.getText()) || !TextUtils.isEmpty(contentWarningEditor.getText()) || !mediaQueued.isEmpty()) { new AlertDialog.Builder(this) .setTitle("Close the toot without saving?") .setPositiveButton(android.R.string.yes, (d, w) -> finish()) .setNegativeButton(android.R.string.no, null) .show(); } else { finish(); } } @Override public List searchAccounts(String mention) { ArrayList resultList = new ArrayList<>(); try { List accountList = mastodonApi.searchAccounts(mention, false, 40) .execute() .body(); if (accountList != null) { resultList.addAll(accountList); } } catch (IOException e) { Log.e(TAG, String.format("Autocomplete search for %s failed.", mention)); } return resultList; } private static final class QueuedMedia { Type type; ProgressImageView preview; Uri uri; String id; Call uploadRequest; URLSpan uploadUrl; ReadyStage readyStage; byte[] content; long mediaSize; String description; 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; } 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]; } }; QueuedMedia.Type type; Uri uri; long mediaSize; QueuedMedia.ReadyStage readyStage; String description; SavedQueuedMedia(QueuedMedia.Type type, Uri uri, long mediaSize, QueuedMedia.ReadyStage readyStage, String description) { this.type = type; this.uri = uri; this.mediaSize = mediaSize; this.readyStage = readyStage; this.description = description; } SavedQueuedMedia(Parcel parcel) { 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.writeSerializable(type); dest.writeParcelable(uri, flags); dest.writeLong(mediaSize); dest.writeString(readyStage.name()); dest.writeString(description); } } @SuppressWarnings("WeakerAccess") public static final class IntentBuilder { @Nullable private Integer savedTootUid; @Nullable private String savedTootText; @Nullable private String savedJsonUrls; @Nullable private Collection mentionedUsernames; @Nullable private String inReplyToId; @Nullable private Status.Visibility replyVisibility; @Nullable private String contentWarning; @Nullable private String replyingStatusAuthor; @Nullable private String replyingStatusContent; public IntentBuilder savedTootUid(int uid) { this.savedTootUid = uid; return this; } public IntentBuilder savedTootText(String savedTootText) { this.savedTootText = savedTootText; return this; } public IntentBuilder savedJsonUrls(String jsonUrls) { this.savedJsonUrls = jsonUrls; 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 repyingStatusAuthor(String username) { this.replyingStatusAuthor = username; return this; } public IntentBuilder replyingStatusContent(String content) { this.replyingStatusContent = content; 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 (savedTootText != null) { intent.putExtra(SAVED_TOOT_TEXT_EXTRA, savedTootText); } if (savedJsonUrls != null) { intent.putExtra(SAVED_JSON_URLS_EXTRA, savedJsonUrls); } 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 (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); } return intent; } } }