/* 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.app.ProgressDialog; import android.content.ContentResolver; 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.database.Cursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.media.MediaMetadataRetriever; import android.media.ThumbnailUtils; 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.provider.MediaStore; import android.provider.OpenableColumns; import android.support.annotation.AttrRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; 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.content.res.AppCompatResources; import android.support.v7.widget.Toolbar; import android.text.Editable; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextWatcher; import android.text.style.URLSpan; import android.view.MenuItem; import android.view.View; 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.ProgressBar; import android.widget.TextView; import com.keylesspalace.tusky.entity.Media; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.fragment.ComposeOptionsFragment; import com.keylesspalace.tusky.util.DownsizeImageTask; import com.keylesspalace.tusky.util.EditTextTyped; import com.keylesspalace.tusky.util.CountUpDownLatch; import com.keylesspalace.tusky.util.IOUtils; import com.keylesspalace.tusky.util.Log; import com.keylesspalace.tusky.util.SpanUtils; import com.keylesspalace.tusky.util.ThemeUtils; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Random; import butterknife.BindView; import butterknife.ButterKnife; import okhttp3.MediaType; import okhttp3.MultipartBody; import okhttp3.RequestBody; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; public class ComposeActivity extends BaseActivity implements ComposeOptionsFragment.Listener { private static final String TAG = "ComposeActivity"; // logging tag private static final int STATUS_CHARACTER_LIMIT = 500; private static final int STATUS_MEDIA_SIZE_LIMIT = 4000000; // 4MB 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 MEDIA_SIZE_UNKNOWN = -1; private static final int COMPOSE_SUCCESS = -1; private static final int THUMBNAIL_SIZE = 128; // pixels private String inReplyToId; private ArrayList mediaQueued; private CountUpDownLatch waitForMediaLatch; private boolean showMarkSensitive; private String 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; // this only exists when a status is trying to be sent, but uploads are still occurring private ProgressDialog finishingUploadDialog; @BindView(R.id.compose_edit_field) EditTextTyped textEditor; @BindView(R.id.compose_media_preview_bar) LinearLayout mediaPreviewBar; @BindView(R.id.compose_content_warning_bar) View contentWarningBar; @BindView(R.id.field_content_warning) EditText contentWarningEditor; @BindView(R.id.characters_left) TextView charactersLeft; @BindView(R.id.floating_btn) Button floatingBtn; @BindView(R.id.compose_photo_pick) ImageButton pickBtn; @BindView(R.id.compose_photo_take) ImageButton takeBtn; @BindView(R.id.action_toggle_nsfw) Button nsfwBtn; @BindView(R.id.postProgress) ProgressBar postProgress; @BindView(R.id.action_toggle_visibility) ImageButton visibilityBtn; private static class QueuedMedia { enum Type { IMAGE, VIDEO } enum ReadyStage { DOWNSIZING, UPLOADING } Type type; ImageView preview; Uri uri; String id; Call uploadRequest; URLSpan uploadUrl; ReadyStage readyStage; byte[] content; long mediaSize; QueuedMedia(Type type, Uri uri, ImageView preview, long mediaSize) { this.type = type; this.uri = uri; this.preview = preview; this.mediaSize = mediaSize; } } /**This saves enough information to re-enqueue an attachment when restoring the activity. */ private static class SavedQueuedMedia implements Parcelable { QueuedMedia.Type type; Uri uri; Bitmap preview; long mediaSize; SavedQueuedMedia(QueuedMedia.Type type, Uri uri, ImageView view, long mediaSize) { this.type = type; this.uri = uri; this.preview = ((BitmapDrawable) view.getDrawable()).getBitmap(); this.mediaSize = mediaSize; } SavedQueuedMedia(Parcel parcel) { type = (QueuedMedia.Type) parcel.readSerializable(); uri = parcel.readParcelable(Uri.class.getClassLoader()); preview = parcel.readParcelable(Bitmap.class.getClassLoader()); mediaSize = parcel.readLong(); } @Override public int describeContents() { return 0; } @Override public void writeToParcel(Parcel dest, int flags) { dest.writeSerializable(type); dest.writeParcelable(uri, flags); dest.writeParcelable(preview, flags); dest.writeLong(mediaSize); } 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]; } }; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_compose); ButterKnife.bind(this); // Setup the toolbar. 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 interface buttons. floatingBtn.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { onSendClicked(); } }); pickBtn.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { onMediaPick(); } }); takeBtn.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { initiateCameraApp(); } }); nsfwBtn.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { toggleNsfw(); } }); visibilityBtn.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { showComposeOptions(); } }); /* Initialise all the state, or restore it from a previous run, to determine a "starting" * state. */ SharedPreferences preferences = getPrivatePreferences(); String startingVisibility; boolean startingHideText; String startingContentWarning = null; ArrayList savedMediaQueued = null; if (savedInstanceState != null) { showMarkSensitive = savedInstanceState.getBoolean("showMarkSensitive"); startingVisibility = savedInstanceState.getString("statusVisibility"); 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); } } else { showMarkSensitive = false; startingVisibility = preferences.getString("rememberedVisibility", "public"); statusMarkSensitive = false; startingHideText = false; } /* 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; inReplyToId = null; if (intent != null) { inReplyToId = intent.getStringExtra("in_reply_to_id"); String replyVisibility = intent.getStringExtra("reply_visibility"); if (replyVisibility != null && startingVisibility != null) { // Lowest possible visibility setting in response if (startingVisibility.equals("private") || replyVisibility.equals("private")) { startingVisibility = "private"; } else if (startingVisibility.equals("unlisted") || replyVisibility.equals("unlisted")) { startingVisibility = "unlisted"; } else { startingVisibility = replyVisibility; } } mentionedUsernames = intent.getStringArrayExtra("mentioned_usernames"); if (inReplyToId != null) { startingHideText = !intent.getStringExtra("content_warning").equals(""); if (startingHideText) { startingContentWarning = intent.getStringExtra("content_warning"); } } } /* If the currently logged in account is locked, its posts should default to private. This * should override even the reply settings, so this must be done after those are set up. */ if (preferences.getBoolean("loggedInAccountLocked", false)) { startingVisibility = "private"; } // After the starting state is finalised, the interface can be set to reflect this state. setStatusVisibility(startingVisibility); postProgress.setVisibility(View.INVISIBLE); updateNsfwButtonColor(); // Setup the main text field. setEditTextMimeTypes(null); // 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); } }); // 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. mediaQueued = new ArrayList<>(); waitForMediaLatch = new CountUpDownLatch(); statusAlreadyInFlight = false; // These can only be added after everything affected by the media queue is initialized. if (savedMediaQueued != null) { for (SavedQueuedMedia item : savedMediaQueued) { addMediaToQueue(item.type, item.preview, item.uri, item.mediaSize); } } 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<>(); 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); } } 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()); } } } } } } @Override protected void onSaveInstanceState(Bundle outState) { ArrayList savedMediaQueued = new ArrayList<>(); for (QueuedMedia item : mediaQueued) { savedMediaQueued.add(new SavedQueuedMedia(item.type, item.uri, item.preview, item.mediaSize)); } outState.putParcelableArrayList("savedMediaQueued", savedMediaQueued); outState.putBoolean("showMarkSensitive", showMarkSensitive); outState.putString("statusVisibility", statusVisibility); 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; 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 toggleNsfw() { statusMarkSensitive = !statusMarkSensitive; updateNsfwButtonColor(); } private void updateNsfwButtonColor() { @AttrRes int attribute; if (statusMarkSensitive) { attribute = R.attr.compose_nsfw_button_selected_color; } else { attribute = R.attr.compose_nsfw_button_color; } nsfwBtn.setTextColor(ThemeUtils.getColor(this, attribute)); } private void disableButtons() { pickBtn.setClickable(false); takeBtn.setClickable(false); nsfwBtn.setClickable(false); visibilityBtn.setClickable(false); floatingBtn.setEnabled(false); } private void enableButtons() { pickBtn.setClickable(true); takeBtn.setClickable(true); nsfwBtn.setClickable(true); visibilityBtn.setClickable(true); floatingBtn.setEnabled(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(String 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, inReplyToId != null); fragment.show(getSupportFragmentManager(), null); } public void onVisibilityChanged(String 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(); } void setStateToReadying() { statusAlreadyInFlight = true; disableButtons(); postProgress.setVisibility(View.VISIBLE); } void setStateToNotReadying() { postProgress.setVisibility(View.INVISIBLE); statusAlreadyInFlight = false; enableButtons(); } private void onSendClicked() { if (statusAlreadyInFlight) { return; } setStateToReadying(); readyStatus(statusVisibility, statusMarkSensitive); } @Override protected void onStop() { super.onStop(); if (inReplyToId != null) { /* Don't save the visibility setting for replies because they adopt the visibility of * the status they reply to and that behaviour needs to be kept separate. */ return; } getPrivatePreferences().edit() .putString("rememberedVisibility", statusVisibility) .apply(); } private void setEditTextMimeTypes(String[] contentMimeTypes) { final String[] mimeTypes; if (contentMimeTypes == null || contentMimeTypes.length == 0) { mimeTypes = new String[0]; } else { mimeTypes = Arrays.copyOf(contentMimeTypes, contentMimeTypes.length); } textEditor.setMimeTypes(mimeTypes, new InputConnectionCompat.OnCommitContentListener() { @Override public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) { return 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) { // 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); currentInputContentInfo = inputContentInfo; currentFlags = flags; return true; } private void sendStatus(String content, String visibility, boolean sensitive, String spoilerText) { ArrayList mediaIds = new ArrayList<>(); for (QueuedMedia item : mediaQueued) { mediaIds.add(item.id); } mastodonAPI.createStatus(content, inReplyToId, spoilerText, visibility, sensitive, mediaIds).enqueue(new Callback() { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) { onSendSuccess(); } else { onSendFailure(); } } @Override public void onFailure(Call call, Throwable t) { onSendFailure(); } }); } private void onSendSuccess() { 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() { textEditor.setError(getString(R.string.error_generic)); setStateToNotReadying(); } private void readyStatus(final String 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); 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(new DialogInterface.OnCancelListener() { @Override public void onCancel(DialogInterface 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(String 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 String visibility, final boolean sensitive) { doErrorDialog(R.string.error_media_upload_sending, R.string.action_retry, new View.OnClickListener() { @Override public void onClick(View v) { readyStatus(visibility, sensitive); } }); setStateToNotReadying(); } private void onMediaPick() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && 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, new View.OnClickListener() { @Override public void onClick(View v) { onMediaPick(); } }); } break; } } } 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() { pickBtn.setEnabled(true); ThemeUtils.setDrawableTint(this, pickBtn.getDrawable(), R.attr.compose_media_button_tint); takeBtn.setEnabled(true); ThemeUtils.setDrawableTint(this, takeBtn.getDrawable(), R.attr.compose_media_button_tint); } private void disableMediaButtons() { pickBtn.setEnabled(false); ThemeUtils.setDrawableTint(this, pickBtn.getDrawable(), R.attr.compose_media_button_disabled_tint); takeBtn.setEnabled(false); ThemeUtils.setDrawableTint(this, takeBtn.getDrawable(), R.attr.compose_media_button_disabled_tint); } private void addMediaToQueue(QueuedMedia.Type type, Bitmap preview, Uri uri, long mediaSize) { final QueuedMedia item = new QueuedMedia(type, uri, new ImageView(this), mediaSize); 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(new View.OnClickListener() { @Override public void onClick(View v) { removeMediaFromQueue(item); } }); 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); } waitForMediaLatch.countUp(); if (mediaSize > STATUS_MEDIA_SIZE_LIMIT && type == QueuedMedia.Type.IMAGE) { downsizeMedia(item); } else { uploadMedia(item); } } 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); } // Remove the text URL associated with this media. if (item.uploadUrl != null) { Editable text = textEditor.getText(); int start = text.getSpanStart(item.uploadUrl); int end = text.getSpanEnd(item.uploadUrl); if (start != -1 && end != -1) { text.delete(start, end); } } enableMediaButtons(); 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) { 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 static String randomAlphanumericString(int count) { char[] chars = new char[count]; Random random = new Random(); final String POSSIBLE_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; for (int i = 0; i < count; i++) { chars[i] = POSSIBLE_CHARS.charAt(random.nextInt(POSSIBLE_CHARS.length())); } return new String(chars); } @Nullable private static byte[] inputStreamGetBytes(InputStream stream) { ByteArrayOutputStream buffer = new ByteArrayOutputStream(); int read; byte[] data = new byte[16384]; try { while ((read = stream.read(data, 0, data.length)) != -1) { buffer.write(data, 0, read); } buffer.flush(); } catch (IOException e) { return null; } return buffer.toByteArray(); } private void uploadMedia(final QueuedMedia item) { item.readyStage = QueuedMedia.ReadyStage.UPLOADING; final 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()), randomAlphanumericString(10), fileExtension); byte[] content = item.content; if (content == null) { InputStream stream; try { stream = getContentResolver().openInputStream(item.uri); } catch (FileNotFoundException e) { return; } content = inputStreamGetBytes(stream); IOUtils.closeQuietly(stream); if (content == null) { return; } } RequestBody requestFile = RequestBody.create(MediaType.parse(mimeType), content); MultipartBody.Part body = MultipartBody.Part.createFormData("file", filename, requestFile); item.uploadRequest = mastodonAPI.uploadMedia(body); item.uploadRequest.enqueue(new Callback() { @Override public void onResponse(Call call, 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(Call call, Throwable t) { Log.d(TAG, t.getMessage()); onUploadFailure(item, false); } }); } private void onUploadSuccess(final QueuedMedia item, Media media) { item.id = media.id; /* 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.textUrl); int end = 1 + media.textUrl.length(); SpannableStringBuilder builder = new SpannableStringBuilder(); builder.append(' '); builder.append(media.textUrl); builder.setSpan(item.uploadUrl, 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); textEditor.append(builder); 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.cancel(); } 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(); } } private static long getMediaSize(ContentResolver contentResolver, Uri uri) { long mediaSize; Cursor cursor = contentResolver.query(uri, null, null, null, null); if (cursor != null) { int sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE); cursor.moveToFirst(); mediaSize = cursor.getLong(sizeIndex); cursor.close(); } else { mediaSize = MEDIA_SIZE_UNKNOWN; } return mediaSize; } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode == MEDIA_PICK_RESULT && resultCode == RESULT_OK && data != null) { Uri uri = data.getData(); long mediaSize = getMediaSize(getContentResolver(), uri); pickMedia(uri, mediaSize); } else if (requestCode == MEDIA_TAKE_PHOTO_RESULT && resultCode == RESULT_OK) { long mediaSize = getMediaSize(getContentResolver(), photoUploadUri); pickMedia(photoUploadUri, mediaSize); } } private void pickMedia(Uri uri, long mediaSize) { ContentResolver contentResolver = getContentResolver(); if (mediaSize == MEDIA_SIZE_UNKNOWN) { displayTransientError(R.string.error_media_upload_opening); return; } 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; } MediaMetadataRetriever retriever = new MediaMetadataRetriever(); retriever.setDataSource(this, uri); Bitmap source = retriever.getFrameAtTime(); Bitmap bitmap = ThumbnailUtils.extractThumbnail(source, THUMBNAIL_SIZE, THUMBNAIL_SIZE); source.recycle(); addMediaToQueue(QueuedMedia.Type.VIDEO, bitmap, uri, mediaSize); break; } case "image": { InputStream stream; try { stream = contentResolver.openInputStream(uri); } catch (FileNotFoundException e) { displayTransientError(R.string.error_media_upload_opening); return; } Bitmap source = BitmapFactory.decodeStream(stream); Bitmap bitmap = ThumbnailUtils.extractThumbnail(source, THUMBNAIL_SIZE, THUMBNAIL_SIZE); source.recycle(); try { if (stream != null) { stream.close(); } } catch (IOException e) { bitmap.recycle(); displayTransientError(R.string.error_media_upload_opening); return; } addMediaToQueue(QueuedMedia.Type.IMAGE, bitmap, uri, mediaSize); break; } default: { displayTransientError(R.string.error_media_upload_type); break; } } } else { displayTransientError(R.string.error_media_upload_type); } } void showMarkSensitive(boolean show) { showMarkSensitive = show; if(!showMarkSensitive) { statusMarkSensitive = false; nsfwBtn.setTextColor(ThemeUtils.getColor(this, R.attr.compose_nsfw_button_color)); } if(show) { nsfwBtn.setVisibility(View.VISIBLE); } else { nsfwBtn.setVisibility(View.GONE); } } 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: { onBackPressed(); return true; } } return super.onOptionsItemSelected(item); } }