net.maa123.tatuky.ComposeActivity.java Source code

Java tutorial

Introduction

Here is the source code for net.maa123.tatuky.ComposeActivity.java

Source

/* 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 <http://www.gnu.org/licenses>. */

package net.maa123.tatuky;

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 net.maa123.tatuky.entity.Media;
import net.maa123.tatuky.entity.Status;
import net.maa123.tatuky.fragment.ComposeOptionsFragment;
import net.maa123.tatuky.util.DownsizeImageTask;
import net.maa123.tatuky.util.EditTextTyped;
import net.maa123.tatuky.util.CountUpDownLatch;
import net.maa123.tatuky.util.IOUtils;
import net.maa123.tatuky.util.Log;
import net.maa123.tatuky.util.SpanUtils;
import net.maa123.tatuky.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<QueuedMedia> 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<Media> 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<SavedQueuedMedia> CREATOR = new Parcelable.Creator<SavedQueuedMedia>() {
            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<SavedQueuedMedia> 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("direct") || replyVisibility.equals("direct")) {
                    startingVisibility = "direct";
                } else 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<Uri> 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<Uri> 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<SavedQueuedMedia> 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<String> mediaIds = new ArrayList<>();

        for (QueuedMedia item : mediaQueued) {
            mediaIds.add(item.id);
        }

        mastodonAPI.createStatus(content, inReplyToId, spoilerText, visibility, sensitive, mediaIds)
                .enqueue(new Callback<Status>() {
                    @Override
                    public void onResponse(Call<Status> call, Response<Status> response) {
                        if (response.isSuccessful()) {
                            onSendSuccess();
                        } else {
                            onSendFailure();
                        }
                    }

                    @Override
                    public void onFailure(Call<Status> 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<Void, Void, Boolean> waitForMediaTask = new AsyncTask<Void, Void, Boolean>() {
            @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, "net.maa123.tatuky.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<QueuedMedia> 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<byte[]> 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<Media>() {
            @Override
            public void onResponse(Call<Media> call, retrofit2.Response<Media> 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<Media> 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);
    }
}