From 387b37e0a88310462cab71ebef91fb50b9ad36fe Mon Sep 17 00:00:00 2001 From: Ivan Kupalov Date: Mon, 30 Oct 2017 01:18:45 +0400 Subject: [PATCH] Add media upload progress. Closes #412 (#426) --- .../com/keylesspalace/tusky/BaseActivity.java | 2 +- .../keylesspalace/tusky/ComposeActivity.java | 59 ++++++++---- .../tusky/network/ProgressRequestBody.java | 78 ++++++++++++++++ .../tusky/view/ProgressImageView.java | 93 +++++++++++++++++++ 4 files changed, 215 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/view/ProgressImageView.java diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index 1421b782..2ab17ff1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -145,7 +145,7 @@ public class BaseActivity extends AppCompatActivity { if (BuildConfig.DEBUG) { okBuilder.addInterceptor( - new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)); + new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BASIC)); } Retrofit retrofit = new Retrofit.Builder().baseUrl(getBaseUrl()) diff --git a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java index 59929110..26411c72 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java @@ -86,6 +86,7 @@ import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Media; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.fragment.ComposeOptionsFragment; +import com.keylesspalace.tusky.network.ProgressRequestBody; import com.keylesspalace.tusky.util.CountUpDownLatch; import com.keylesspalace.tusky.util.DownsizeImageTask; import com.keylesspalace.tusky.util.IOUtils; @@ -96,6 +97,7 @@ 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.squareup.picasso.Target; @@ -115,7 +117,6 @@ import java.util.Locale; import okhttp3.MediaType; import okhttp3.MultipartBody; -import okhttp3.RequestBody; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; @@ -317,7 +318,8 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm if (!TextUtils.isEmpty(savedJsonUrls)) { // try to redo a list of media loadedDraftMediaUris = new Gson().fromJson(savedJsonUrls, - new TypeToken>() {}.getType()); + new TypeToken>() { + }.getType()); } int savedTootUid = intent.getIntExtra("saved_toot_uid", 0); @@ -399,7 +401,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm statusAlreadyInFlight = false; // These can only be added after everything affected by the media queue is initialized. - if (!ListUtils.isEmpty(loadedDraftMediaUris)) { + if (!ListUtils.isEmpty(loadedDraftMediaUris)) { for (String uriString : loadedDraftMediaUris) { Uri uri = Uri.parse(uriString); long mediaSize = MediaUtils.getMediaSize(getContentResolver(), uri); @@ -650,6 +652,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm /** * 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) { @@ -672,7 +675,8 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm String savedJsonUrls = getIntent().getStringExtra("saved_json_urls"); if (!TextUtils.isEmpty(savedJsonUrls)) { existingUris = new Gson().fromJson(savedJsonUrls, - new TypeToken>() {}.getType()); + new TypeToken>() { + }.getType()); } final TootEntity toot = new TootEntity(); @@ -683,7 +687,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm if (!ListUtils.isEmpty(savedList)) { String json = new Gson().toJson(savedList); toot.setUrls(json); - if(!ListUtils.isEmpty(existingUris)) { + if (!ListUtils.isEmpty(existingUris)) { deleteMedia(setDifference(existingUris, savedList)); } } else { @@ -836,7 +840,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm } private boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, - String[] mimeTypes) { + String[] mimeTypes) { try { if (currentInputContentInfo != null) { currentInputContentInfo.releasePermission(); @@ -898,7 +902,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm } private void sendStatus(String content, String visibility, boolean sensitive, - String spoilerText) { + String spoilerText) { ArrayList mediaIds = new ArrayList<>(); for (QueuedMedia item : mediaQueued) { @@ -1068,7 +1072,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm @Override public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[], - @NonNull int[] grantResults) { + @NonNull int[] grantResults) { switch (requestCode) { case PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE: { if (grantResults.length > 0 @@ -1150,7 +1154,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm } private void addMediaToQueue(QueuedMedia.Type type, Bitmap preview, Uri uri, long mediaSize, QueuedMedia.ReadyStage readyStage) { - final QueuedMedia item = new QueuedMedia(type, uri, new ImageView(this), mediaSize); + final QueuedMedia item = new QueuedMedia(type, uri, new ProgressImageView(this), mediaSize); item.readyStage = readyStage; ImageView view = item.preview; Resources resources = getResources(); @@ -1261,7 +1265,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm private void uploadMedia(final QueuedMedia item) { item.readyStage = QueuedMedia.ReadyStage.UPLOADING; - final String mimeType = getContentResolver().getType(item.uri); + String mimeType = getContentResolver().getType(item.uri); MimeTypeMap map = MimeTypeMap.getSingleton(); String fileExtension = map.getExtensionFromMimeType(mimeType); final String filename = String.format("%s_%s_%s.%s", @@ -1290,14 +1294,36 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm } } - RequestBody requestFile = RequestBody.create(MediaType.parse(mimeType), content); - MultipartBody.Part body = MultipartBody.Part.createFormData("file", filename, requestFile); + 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(new Runnable() { + @Override + public void run() { + 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(Call call, retrofit2.Response response) { + public void onResponse(@NonNull Call call, @NonNull retrofit2.Response response) { if (response.isSuccessful()) { onUploadSuccess(item, response.body()); } else { @@ -1307,7 +1333,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm } @Override - public void onFailure(Call call, Throwable t) { + public void onFailure(@NonNull Call call, @NonNull Throwable t) { Log.d(TAG, "Upload request failed. " + t.getMessage()); onUploadFailure(item, call.isCanceled()); } @@ -1316,6 +1342,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm private void onUploadSuccess(final QueuedMedia item, Media media) { item.id = media.id; + 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 @@ -1517,7 +1544,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm private static class QueuedMedia { Type type; - ImageView preview; + ProgressImageView preview; Uri uri; String id; Call uploadRequest; @@ -1526,7 +1553,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm byte[] content; long mediaSize; - QueuedMedia(Type type, Uri uri, ImageView preview, long mediaSize) { + QueuedMedia(Type type, Uri uri, ProgressImageView preview, long mediaSize) { this.type = type; this.uri = uri; this.preview = preview; diff --git a/app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.java b/app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.java new file mode 100644 index 00000000..8e010526 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.java @@ -0,0 +1,78 @@ +/* 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.network; + +import android.support.annotation.NonNull; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +import okhttp3.MediaType; +import okhttp3.RequestBody; +import okio.BufferedSink; + +public final class ProgressRequestBody extends RequestBody { + private final byte[] content; + private final UploadCallback mListener; + private final MediaType mediaType; + private boolean shouldIgnoreThisPass; + + private static final int DEFAULT_BUFFER_SIZE = 2048; + + public interface UploadCallback { + void onProgressUpdate(int percentage); + } + + public ProgressRequestBody(final byte[] content, final MediaType mediaType, boolean shouldIgnoreFirst, final UploadCallback listener) { + this.content = content; + this.mediaType = mediaType; + mListener = listener; + shouldIgnoreThisPass = shouldIgnoreFirst; + } + + @Override + public MediaType contentType() { + return mediaType; + } + + @Override + public long contentLength() throws IOException { + return content.length; + } + + @Override + public void writeTo(@NonNull BufferedSink sink) throws IOException { + long length = content.length; + + byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; + ByteArrayInputStream in = new ByteArrayInputStream(content); + long uploaded = 0; + + try { + int read; + while ((read = in.read(buffer)) != -1) { + if (!shouldIgnoreThisPass) { + mListener.onProgressUpdate((int)(100 * uploaded / length)); + } + uploaded += read; + sink.write(buffer, 0, read); + } + } finally { + in.close(); + } + shouldIgnoreThisPass = false; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ProgressImageView.java b/app/src/main/java/com/keylesspalace/tusky/view/ProgressImageView.java new file mode 100644 index 00000000..c6c215df --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/ProgressImageView.java @@ -0,0 +1,93 @@ +/* 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.view; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.RectF; +import android.support.annotation.Nullable; +import android.support.v4.content.ContextCompat; +import android.support.v7.widget.AppCompatImageView; +import android.util.AttributeSet; + +import com.keylesspalace.tusky.R; +import com.varunest.sparkbutton.helpers.Utils; + +public final class ProgressImageView extends AppCompatImageView { + + private int progress = -1; + private RectF progressRect = new RectF(); + private RectF biggerRect = new RectF(); + private Paint circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private Paint clearPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + + public ProgressImageView(Context context) { + super(context); + init(); + } + + public ProgressImageView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ProgressImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + circlePaint.setColor(ContextCompat.getColor(getContext(), R.color.colorPrimary)); + circlePaint.setStrokeWidth(Utils.dpToPx(getContext(), 4)); + circlePaint.setStyle(Paint.Style.STROKE); + + clearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT)); + } + + public void setProgress(int progress) { + this.progress = progress; + if (progress != -1) { + setColorFilter(Color.rgb(123, 123, 123), PorterDuff.Mode.MULTIPLY); + } else { + clearColorFilter(); + } + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + if (progress == -1) { + return; + } + + float angle = (progress / 100f) * 360 - 90; + float halfWidth = canvas.getWidth() / 2; + float halfHeight = canvas.getHeight() / 2; + progressRect.set(halfWidth * 0.75f, halfHeight * 0.75f, halfWidth * 1.25f, halfHeight * 1.25f); + biggerRect.set(progressRect); + int margin = 8; + biggerRect.set(progressRect.left - margin, progressRect.top - margin, progressRect.right + margin, progressRect.bottom + margin); + canvas.saveLayer(biggerRect, null, Canvas.ALL_SAVE_FLAG); + canvas.drawOval(progressRect, circlePaint); + canvas.drawArc(biggerRect, angle, 360 - angle - 90, true, clearPaint); + canvas.restore(); + } +}