From 01970ab69b50c297d59c015bc9628705576e378f Mon Sep 17 00:00:00 2001 From: Grishka Date: Tue, 4 Oct 2022 07:35:31 +0300 Subject: [PATCH] Compose media attachment redesign --- mastodon/build.gradle | 2 +- .../android/api/CacheController.java | 4 +- .../android/api/MastodonAPIController.java | 16 +- .../android/api/MastodonAPIRequest.java | 6 +- .../api/MastodonDetailedErrorResponse.java | 4 +- .../android/api/MastodonErrorResponse.java | 4 +- .../requests/statuses/GetAttachmentByID.java | 21 ++ .../requests/statuses/UploadAttachment.java | 13 + .../android/fragments/ComposeFragment.java | 348 ++++++++++++++---- .../ui/utils/TransferSpeedTracker.java | 51 +++ .../android/ui/utils/UiUtils.java | 20 + .../main/res/drawable/bg_upload_progress.xml | 8 + .../ic_fluent_arrow_clockwise_24_filled.xml | 3 + .../drawable/ic_fluent_dismiss_24_filled.xml | 3 + .../src/main/res/drawable/upload_progress.xml | 2 +- .../main/res/layout/compose_media_thumb.xml | 75 +++- mastodon/src/main/res/values/strings.xml | 11 +- 17 files changed, 484 insertions(+), 107 deletions(-) create mode 100644 mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetAttachmentByID.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/ui/utils/TransferSpeedTracker.java create mode 100644 mastodon/src/main/res/drawable/bg_upload_progress.xml create mode 100644 mastodon/src/main/res/drawable/ic_fluent_arrow_clockwise_24_filled.xml create mode 100644 mastodon/src/main/res/drawable/ic_fluent_dismiss_24_filled.xml diff --git a/mastodon/build.gradle b/mastodon/build.gradle index fb5c64f4..f87a1411 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -9,7 +9,7 @@ android { applicationId "org.joinmastodon.android" minSdk 23 targetSdk 33 - versionCode 40 + versionCode 41 versionName "1.1.3" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java index f728649b..830b5a19 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java @@ -102,7 +102,7 @@ public class CacheController{ .exec(accountID); }catch(SQLiteException x){ Log.w(TAG, x); - uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500))); + uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500, x))); }finally{ closeDelayed(); } @@ -184,7 +184,7 @@ public class CacheController{ .exec(accountID); }catch(SQLiteException x){ Log.w(TAG, x); - uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500))); + uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500, x))); }finally{ closeDelayed(); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java index e0132580..c27941de 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java @@ -100,7 +100,7 @@ public class MastodonAPIController{ synchronized(req){ req.okhttpCall=null; } - req.onError(e.getLocalizedMessage(), 0); + req.onError(e.getLocalizedMessage(), 0, e); } @Override @@ -133,7 +133,7 @@ public class MastodonAPIController{ }catch(JsonIOException|JsonSyntaxException x){ if(BuildConfig.DEBUG) Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error parsing or reading body", x); - req.onError(x.getLocalizedMessage(), response.code()); + req.onError(x.getLocalizedMessage(), response.code(), x); return; } @@ -142,7 +142,7 @@ public class MastodonAPIController{ }catch(IOException x){ if(BuildConfig.DEBUG) Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error post-processing or validating response", x); - req.onError(x.getLocalizedMessage(), response.code()); + req.onError(x.getLocalizedMessage(), response.code(), x); return; } @@ -155,7 +155,7 @@ public class MastodonAPIController{ JsonObject error=JsonParser.parseReader(reader).getAsJsonObject(); Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" received error: "+error); if(error.has("details")){ - MastodonDetailedErrorResponse err=new MastodonDetailedErrorResponse(error.get("error").getAsString(), response.code()); + MastodonDetailedErrorResponse err=new MastodonDetailedErrorResponse(error.get("error").getAsString(), response.code(), null); HashMap> details=new HashMap<>(); JsonObject errorDetails=error.getAsJsonObject("details"); for(String key:errorDetails.keySet()){ @@ -172,12 +172,12 @@ public class MastodonAPIController{ err.detailedErrors=details; req.onError(err); }else{ - req.onError(error.get("error").getAsString(), response.code()); + req.onError(error.get("error").getAsString(), response.code(), null); } }catch(JsonIOException|JsonSyntaxException x){ - req.onError(response.code()+" "+response.message(), response.code()); + req.onError(response.code()+" "+response.message(), response.code(), x); }catch(Exception x){ - req.onError("Error parsing an API error", response.code()); + req.onError("Error parsing an API error", response.code(), x); } } }catch(Exception x){ @@ -189,7 +189,7 @@ public class MastodonAPIController{ }catch(Exception x){ if(BuildConfig.DEBUG) Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] error creating and sending http request", x); - req.onError(x.getLocalizedMessage(), 0); + req.onError(x.getLocalizedMessage(), 0, x); } }, 0); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java index 3d8adffd..0b822883 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java @@ -82,7 +82,7 @@ public abstract class MastodonAPIRequest extends APIRequest{ account.getApiController().submitRequest(this); }catch(Exception x){ Log.e(TAG, "exec: this shouldn't happen, but it still did", x); - invokeErrorCallback(new MastodonErrorResponse(x.getLocalizedMessage(), -1)); + invokeErrorCallback(new MastodonErrorResponse(x.getLocalizedMessage(), -1, x)); } return this; } @@ -194,8 +194,8 @@ public abstract class MastodonAPIRequest extends APIRequest{ invokeErrorCallback(err); } - void onError(String msg, int httpStatus){ - invokeErrorCallback(new MastodonErrorResponse(msg, httpStatus)); + void onError(String msg, int httpStatus, Throwable exception){ + invokeErrorCallback(new MastodonErrorResponse(msg, httpStatus, exception)); } void onSuccess(T resp){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonDetailedErrorResponse.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonDetailedErrorResponse.java index 61ac1cdb..f0b86a31 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonDetailedErrorResponse.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonDetailedErrorResponse.java @@ -7,8 +7,8 @@ import java.util.Map; public class MastodonDetailedErrorResponse extends MastodonErrorResponse{ public Map> detailedErrors; - public MastodonDetailedErrorResponse(String error, int httpStatus){ - super(error, httpStatus); + public MastodonDetailedErrorResponse(String error, int httpStatus, Throwable exception){ + super(error, httpStatus, exception); } public static class FieldError{ diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java index 4e24629e..9dfbfdc8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java @@ -12,10 +12,12 @@ import me.grishka.appkit.api.ErrorResponse; public class MastodonErrorResponse extends ErrorResponse{ public final String error; public final int httpStatus; + public final Throwable underlyingException; - public MastodonErrorResponse(String error, int httpStatus){ + public MastodonErrorResponse(String error, int httpStatus, Throwable exception){ this.error=error; this.httpStatus=httpStatus; + this.underlyingException=exception; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetAttachmentByID.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetAttachmentByID.java new file mode 100644 index 00000000..0228ade2 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetAttachmentByID.java @@ -0,0 +1,21 @@ +package org.joinmastodon.android.api.requests.statuses; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Attachment; + +import java.io.IOException; + +import okhttp3.Response; + +public class GetAttachmentByID extends MastodonAPIRequest{ + public GetAttachmentByID(String id){ + super(HttpMethod.GET, "/media/"+id, Attachment.class); + } + + @Override + public void validateAndPostprocessResponse(Attachment respObj, Response httpResponse) throws IOException{ + if(httpResponse.code()==206) + respObj.url=""; + super.validateAndPostprocessResponse(respObj, httpResponse); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/UploadAttachment.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/UploadAttachment.java index 9a570d8b..6cb29da1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/UploadAttachment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/UploadAttachment.java @@ -17,6 +17,7 @@ import java.io.IOException; import okhttp3.MultipartBody; import okhttp3.RequestBody; +import okhttp3.Response; public class UploadAttachment extends MastodonAPIRequest{ private Uri uri; @@ -40,6 +41,18 @@ public class UploadAttachment extends MastodonAPIRequest{ return this; } + @Override + protected String getPathPrefix(){ + return "/api/v2"; + } + + @Override + public void validateAndPostprocessResponse(Attachment respObj, Response httpResponse) throws IOException{ + if(respObj.url==null) + respObj.url=""; + super.validateAndPostprocessResponse(respObj, httpResponse); + } + @Override public RequestBody getRequestBody() throws IOException{ MultipartBody.Builder builder=new MultipartBody.Builder() diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java index 2f0f7115..566a8d55 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java @@ -4,13 +4,18 @@ import android.animation.ObjectAnimator; import android.annotation.SuppressLint; import android.app.Activity; import android.content.ClipData; +import android.content.Context; import android.content.Intent; import android.content.res.Configuration; import android.database.Cursor; +import android.graphics.Bitmap; import android.graphics.Outline; import android.graphics.PixelFormat; +import android.graphics.RenderEffect; +import android.graphics.Shader; import android.graphics.drawable.LayerDrawable; import android.icu.text.BreakIterator; +import android.media.MediaMetadataRetriever; import android.net.Uri; import android.os.Build; import android.os.Bundle; @@ -51,9 +56,12 @@ import com.twitter.twittertext.TwitterTextEmojiRegex; import org.joinmastodon.android.E; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.MastodonAPIController; +import org.joinmastodon.android.api.MastodonErrorResponse; import org.joinmastodon.android.api.ProgressListener; import org.joinmastodon.android.api.requests.statuses.CreateStatus; import org.joinmastodon.android.api.requests.statuses.EditStatus; +import org.joinmastodon.android.api.requests.statuses.GetAttachmentByID; import org.joinmastodon.android.api.requests.statuses.UploadAttachment; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; @@ -78,6 +86,7 @@ import org.joinmastodon.android.ui.text.ComposeAutocompleteSpan; import org.joinmastodon.android.ui.text.ComposeHashtagOrMentionSpan; import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.SimpleTextWatcher; +import org.joinmastodon.android.ui.utils.TransferSpeedTracker; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.ComposeEditText; import org.joinmastodon.android.ui.views.ComposeMediaLayout; @@ -86,6 +95,9 @@ import org.joinmastodon.android.ui.views.SizeListenerLinearLayout; import org.parceler.Parcel; import org.parceler.Parcels; +import java.io.InterruptedIOException; +import java.net.SocketException; +import java.net.UnknownHostException; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -107,6 +119,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private static final int MEDIA_RESULT=717; private static final int IMAGE_DESCRIPTION_RESULT=363; private static final int MAX_ATTACHMENTS=4; + private static final String TAG="ComposeFragment"; private static final Pattern MENTION_PATTERN=Pattern.compile("(^|[^\\/\\w])@(([a-z0-9_]+)@[a-z0-9\\.\\-]+[a-z0-9]+)", Pattern.CASE_INSENSITIVE); @@ -154,8 +167,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private ArrayList pollOptions=new ArrayList<>(); - private ArrayList queuedAttachments=new ArrayList<>(), failedAttachments=new ArrayList<>(), attachments=new ArrayList<>(), allAttachments=new ArrayList<>(); - private DraftMediaAttachment uploadingAttachment; + private ArrayList attachments=new ArrayList<>(); private List customEmojis; private CustomEmojiPopupKeyboard emojiKeyboard; @@ -181,6 +193,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private boolean pollChanged; private boolean creatingView; private boolean ignoreSelectionChanges=false; + private Runnable updateUploadEtaRunnable; @Override public void onCreate(Bundle savedInstanceState){ @@ -223,8 +236,14 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void onDestroy(){ super.onDestroy(); - if(uploadingAttachment!=null && uploadingAttachment.uploadRequest!=null) - uploadingAttachment.uploadRequest.cancel(); + for(DraftMediaAttachment att:attachments){ + if(att.isUploadingOrProcessing()) + att.cancelUpload(); + } + if(updateUploadEtaRunnable!=null){ + UiUtils.removeCallbacks(updateUploadEtaRunnable); + updateUploadEtaRunnable=null; + } } @Override @@ -344,9 +363,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr attachments.add(att); } attachmentsView.setVisibility(View.VISIBLE); - }else if(!allAttachments.isEmpty()){ + }else if(!attachments.isEmpty()){ attachmentsView.setVisibility(View.VISIBLE); - for(DraftMediaAttachment att:allAttachments){ + for(DraftMediaAttachment att:attachments){ attachmentsView.addView(createMediaAttachmentView(att)); } } @@ -611,8 +630,12 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } if(publishButton==null) return; - publishButton.setEnabled((trimmedCharCount>0 || !attachments.isEmpty()) && charCount<=charLimit && uploadingAttachment==null && failedAttachments.isEmpty() && queuedAttachments.isEmpty() - && (pollOptions.isEmpty() || nonEmptyPollOptionsCount>1)); + int nonDoneAttachmentCount=0; + for(DraftMediaAttachment att:attachments){ + if(att.state!=AttachmentUploadState.DONE) + nonDoneAttachmentCount++; + } + publishButton.setEnabled((trimmedCharCount>0 || !attachments.isEmpty()) && charCount<=charLimit && nonDoneAttachmentCount==0 && (pollOptions.isEmpty() || nonEmptyPollOptionsCount>1)); } private void onCustomEmojiClick(Emoji emoji){ @@ -719,8 +742,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr boolean pollFieldsHaveContent=false; for(DraftPollOption opt:pollOptions) pollFieldsHaveContent|=opt.edit.length()>0; - return (mainEditText.length()>0 && !mainEditText.getText().toString().equals(initialText)) || !attachments.isEmpty() - || uploadingAttachment!=null || !queuedAttachments.isEmpty() || !failedAttachments.isEmpty() || pollFieldsHaveContent; + return (mainEditText.length()>0 && !mainEditText.getText().toString().equals(initialText)) || !attachments.isEmpty() || pollFieldsHaveContent; } @Override @@ -821,7 +843,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } if(size>sizeLimit){ float mb=sizeLimit/(float) (1024*1024); - String sMb=String.format(Locale.getDefault(), mb%1f==0f ? "%f" : "%.2f", mb); + String sMb=String.format(Locale.getDefault(), mb%1f==0f ? "%.0f" : "%.2f", mb); showMediaAttachmentError(getString(R.string.media_attachment_too_big, UiUtils.getFileName(uri), sMb)); return false; } @@ -830,18 +852,16 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr pollBtn.setEnabled(false); DraftMediaAttachment draft=new DraftMediaAttachment(); draft.uri=uri; + draft.mimeType=type; draft.description=description; attachmentsView.addView(createMediaAttachmentView(draft)); - allAttachments.add(draft); + attachments.add(draft); attachmentsView.setVisibility(View.VISIBLE); - draft.overlay.setVisibility(View.VISIBLE); - draft.infoBar.setVisibility(View.GONE); + draft.setOverlayVisible(true, false); - if(uploadingAttachment==null){ - uploadMediaAttachment(draft); - }else{ - queuedAttachments.add(draft); + if(!areThereAnyUploadingAttachments()){ + uploadNextQueuedAttachment(); } updatePublishButtonState(); if(getMediaAttachmentsCount()==MAX_ATTACHMENTS) @@ -860,25 +880,31 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private View createMediaAttachmentView(DraftMediaAttachment draft){ View thumb=getActivity().getLayoutInflater().inflate(R.layout.compose_media_thumb, attachmentsView, false); ImageView img=thumb.findViewById(R.id.thumb); - ViewImageLoader.load(img, null, new UrlImageLoaderRequest(draft.uri, V.dp(250), V.dp(250))); + if(draft.mimeType.startsWith("image/")){ + ViewImageLoader.load(img, null, new UrlImageLoaderRequest(draft.uri, V.dp(250), V.dp(250))); + }else if(draft.mimeType.startsWith("video/")){ + loadVideoThumbIntoView(img, draft.uri); + } TextView fileName=thumb.findViewById(R.id.file_name); fileName.setText(UiUtils.getFileName(draft.uri)); draft.view=thumb; + draft.imageView=img; draft.progressBar=thumb.findViewById(R.id.progress); draft.infoBar=thumb.findViewById(R.id.info_bar); draft.overlay=thumb.findViewById(R.id.overlay); draft.descriptionView=thumb.findViewById(R.id.description); + draft.uploadStateTitle=thumb.findViewById(R.id.state_title); + draft.uploadStateText=thumb.findViewById(R.id.state_text); ImageButton btn=thumb.findViewById(R.id.remove_btn); btn.setTag(draft); btn.setOnClickListener(this::onRemoveMediaAttachmentClick); btn=thumb.findViewById(R.id.remove_btn2); btn.setTag(draft); btn.setOnClickListener(this::onRemoveMediaAttachmentClick); - Button retry=thumb.findViewById(R.id.retry_upload); + ImageButton retry=thumb.findViewById(R.id.retry_or_cancel_upload); retry.setTag(draft); - retry.setOnClickListener(this::onRetryMediaUploadClick); - retry.setVisibility(View.GONE); + retry.setOnClickListener(this::onRetryOrCancelMediaUploadClick); draft.retryButton=retry; draft.infoBar.setTag(draft); draft.infoBar.setOnClickListener(this::onEditMediaDescriptionClick); @@ -886,12 +912,14 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr if(!TextUtils.isEmpty(draft.description)) draft.descriptionView.setText(draft.description); - if(uploadingAttachment!=draft && !queuedAttachments.contains(draft)){ - draft.progressBar.setVisibility(View.GONE); + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.S){ + draft.overlay.setBackgroundColor(0xA6000000); } - if(failedAttachments.contains(draft)){ - draft.infoBar.setVisibility(View.GONE); - draft.overlay.setVisibility(View.VISIBLE); + + if(draft.state==AttachmentUploadState.UPLOADING || draft.state==AttachmentUploadState.PROCESSING || draft.state==AttachmentUploadState.QUEUED){ + draft.progressBar.setVisibility(View.GONE); + }else if(draft.state==AttachmentUploadState.ERROR){ + draft.setOverlayVisible(true, false); } return thumb; @@ -903,67 +931,92 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr draft.uri=uri; draft.description=description; attachmentsView.addView(createMediaAttachmentView(draft)); - allAttachments.add(draft); + attachments.add(draft); attachmentsView.setVisibility(View.VISIBLE); } private void uploadMediaAttachment(DraftMediaAttachment attachment){ - if(uploadingAttachment!=null) - throw new IllegalStateException("there is already an attachment being uploaded"); - uploadingAttachment=attachment; + if(areThereAnyUploadingAttachments()){ + throw new IllegalStateException("there is already an attachment being uploaded"); + } + attachment.state=AttachmentUploadState.UPLOADING; attachment.progressBar.setVisibility(View.VISIBLE); ObjectAnimator rotationAnimator=ObjectAnimator.ofFloat(attachment.progressBar, View.ROTATION, 0f, 360f); rotationAnimator.setInterpolator(new LinearInterpolator()); rotationAnimator.setDuration(1500); rotationAnimator.setRepeatCount(ObjectAnimator.INFINITE); rotationAnimator.start(); + attachment.progressBarAnimator=rotationAnimator; int maxSize=0; String contentType=getActivity().getContentResolver().getType(attachment.uri); if(contentType!=null && contentType.startsWith("image/")){ maxSize=2_073_600; // TODO get this from instance configuration when it gets added there } + attachment.uploadStateTitle.setText(""); + attachment.uploadStateText.setText(""); + attachment.progressBar.setProgress(0); + attachment.speedTracker.reset(); + attachment.speedTracker.addSample(0); attachment.uploadRequest=(UploadAttachment) new UploadAttachment(attachment.uri, maxSize, attachment.description) .setProgressListener(new ProgressListener(){ @Override public void onProgress(long transferred, long total){ + if(updateUploadEtaRunnable==null){ + UiUtils.runOnUiThread(updateUploadEtaRunnable=ComposeFragment.this::updateUploadETAs, 100); + } int progress=Math.round(transferred/(float)total*attachment.progressBar.getMax()); if(Build.VERSION.SDK_INT>=24) attachment.progressBar.setProgress(progress, true); else attachment.progressBar.setProgress(progress); + + attachment.speedTracker.setTotalBytes(total); + attachment.uploadStateTitle.setText(getString(R.string.file_upload_progress, UiUtils.formatFileSize(getActivity(), transferred, true), UiUtils.formatFileSize(getActivity(), total, true))); + attachment.speedTracker.addSample(transferred); } }) .setCallback(new Callback<>(){ @Override public void onSuccess(Attachment result){ attachment.serverAttachment=result; - attachment.uploadRequest=null; - uploadingAttachment=null; - attachments.add(attachment); - attachment.progressBar.setVisibility(View.GONE); - if(!queuedAttachments.isEmpty()) - uploadMediaAttachment(queuedAttachments.remove(0)); - updatePublishButtonState(); - - rotationAnimator.cancel(); - V.setVisibilityAnimated(attachment.overlay, View.GONE); - V.setVisibilityAnimated(attachment.infoBar, View.VISIBLE); + if(TextUtils.isEmpty(result.url)){ + attachment.state=AttachmentUploadState.PROCESSING; + attachment.processingPollingRunnable=()->pollForMediaAttachmentProcessing(attachment); + if(getActivity()==null) + return; + attachment.uploadStateTitle.setText(R.string.upload_processing); + attachment.uploadStateText.setText(""); + UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000); + if(!areThereAnyUploadingAttachments()) + uploadNextQueuedAttachment(); + }else{ + finishMediaAttachmentUpload(attachment); + } } @Override public void onError(ErrorResponse error){ attachment.uploadRequest=null; - uploadingAttachment=null; - failedAttachments.add(attachment); -// error.showToast(getActivity()); - Toast.makeText(getActivity(), R.string.image_upload_failed, Toast.LENGTH_SHORT).show(); + attachment.progressBarAnimator=null; + attachment.state=AttachmentUploadState.ERROR; + attachment.uploadStateTitle.setText(R.string.upload_failed); + if(error instanceof MastodonErrorResponse er){ + if(er.underlyingException instanceof SocketException || er.underlyingException instanceof UnknownHostException || er.underlyingException instanceof InterruptedIOException) + attachment.uploadStateText.setText(R.string.upload_error_connection_lost); + else + attachment.uploadStateText.setText(er.error); + }else{ + attachment.uploadStateText.setText(""); + } + attachment.retryButton.setImageResource(R.drawable.ic_fluent_arrow_clockwise_24_filled); + attachment.retryButton.setContentDescription(getString(R.string.retry_upload)); rotationAnimator.cancel(); V.setVisibilityAnimated(attachment.retryButton, View.VISIBLE); V.setVisibilityAnimated(attachment.progressBar, View.GONE); - if(!queuedAttachments.isEmpty()) - uploadMediaAttachment(queuedAttachments.remove(0)); + if(!areThereAnyUploadingAttachments()) + uploadNextQueuedAttachment(); } }) .exec(accountID); @@ -971,37 +1024,109 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private void onRemoveMediaAttachmentClick(View v){ DraftMediaAttachment att=(DraftMediaAttachment) v.getTag(); - if(att==uploadingAttachment){ - att.uploadRequest.cancel(); - uploadingAttachment=null; - if(!queuedAttachments.isEmpty()) - uploadMediaAttachment(queuedAttachments.remove(0)); - }else{ - attachments.remove(att); - queuedAttachments.remove(att); - failedAttachments.remove(att); - } - allAttachments.remove(att); + if(att.isUploadingOrProcessing()) + att.cancelUpload(); + attachments.remove(att); + uploadNextQueuedAttachment(); attachmentsView.removeView(att.view); if(getMediaAttachmentsCount()==0) attachmentsView.setVisibility(View.GONE); updatePublishButtonState(); - pollBtn.setEnabled(attachments.isEmpty() && queuedAttachments.isEmpty() && failedAttachments.isEmpty() && uploadingAttachment==null); + pollBtn.setEnabled(attachments.isEmpty()); mediaBtn.setEnabled(true); } - private void onRetryMediaUploadClick(View v){ + private void onRetryOrCancelMediaUploadClick(View v){ DraftMediaAttachment att=(DraftMediaAttachment) v.getTag(); - if(failedAttachments.remove(att)){ - V.setVisibilityAnimated(att.retryButton, View.GONE); + if(att.state==AttachmentUploadState.ERROR){ + att.retryButton.setImageResource(R.drawable.ic_fluent_dismiss_24_filled); + att.retryButton.setContentDescription(getString(R.string.cancel)); V.setVisibilityAnimated(att.progressBar, View.VISIBLE); - if(uploadingAttachment==null) - uploadMediaAttachment(att); - else - queuedAttachments.add(att); + att.state=AttachmentUploadState.QUEUED; + if(!areThereAnyUploadingAttachments()){ + uploadNextQueuedAttachment(); + } + }else{ + onRemoveMediaAttachmentClick(v); } } + private void pollForMediaAttachmentProcessing(DraftMediaAttachment attachment){ + attachment.processingPollingRequest=(GetAttachmentByID) new GetAttachmentByID(attachment.serverAttachment.id) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Attachment result){ + attachment.processingPollingRequest=null; + if(!TextUtils.isEmpty(result.url)){ + attachment.processingPollingRunnable=null; + attachment.serverAttachment=result; + finishMediaAttachmentUpload(attachment); + }else if(getActivity()!=null){ + UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000); + } + } + + @Override + public void onError(ErrorResponse error){ + attachment.processingPollingRequest=null; + if(getActivity()!=null) + UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000); + } + }) + .exec(accountID); + } + + private void finishMediaAttachmentUpload(DraftMediaAttachment attachment){ + if(attachment.state!=AttachmentUploadState.PROCESSING && attachment.state!=AttachmentUploadState.UPLOADING) + throw new IllegalStateException("Unexpected state "+attachment.state); + attachment.uploadRequest=null; + attachment.state=AttachmentUploadState.DONE; + attachment.progressBar.setVisibility(View.GONE); + if(!areThereAnyUploadingAttachments()) + uploadNextQueuedAttachment(); + updatePublishButtonState(); + + if(attachment.progressBarAnimator!=null){ + attachment.progressBarAnimator.cancel(); + attachment.progressBarAnimator=null; + } + attachment.setOverlayVisible(false, true); + } + + private void uploadNextQueuedAttachment(){ + for(DraftMediaAttachment att:attachments){ + if(att.state==AttachmentUploadState.QUEUED){ + uploadMediaAttachment(att); + return; + } + } + } + + private boolean areThereAnyUploadingAttachments(){ + for(DraftMediaAttachment att:attachments){ + if(att.state==AttachmentUploadState.UPLOADING) + return true; + } + return false; + } + + private void updateUploadETAs(){ + if(!areThereAnyUploadingAttachments()){ + UiUtils.removeCallbacks(updateUploadEtaRunnable); + updateUploadEtaRunnable=null; + return; + } + for(DraftMediaAttachment att:attachments){ + if(att.state==AttachmentUploadState.UPLOADING){ + long eta=att.speedTracker.updateAndGetETA(); +// Log.i(TAG, "onProgress: transfer speed "+UiUtils.formatFileSize(getActivity(), Math.round(att.speedTracker.getLastSpeed()), false)+" average "+UiUtils.formatFileSize(getActivity(), Math.round(att.speedTracker.getAverageSpeed()), false)+" eta "+eta); + String time=String.format("%d:%02d", eta/60, eta%60); + att.uploadStateText.setText(getString(R.string.file_upload_time_remaining, time)); + } + } + UiUtils.runOnUiThread(updateUploadEtaRunnable, 100); + } + private void onEditMediaDescriptionClick(View v){ DraftMediaAttachment att=(DraftMediaAttachment) v.getTag(); if(att.serverAttachment==null) @@ -1114,7 +1239,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } private int getMediaAttachmentsCount(){ - return allAttachments.size(); + return attachments.size(); } private void onVisibilityClick(View v){ @@ -1233,6 +1358,30 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr finishAutocomplete(); } + private void loadVideoThumbIntoView(ImageView target, Uri uri){ + MastodonAPIController.runInBackground(()->{ + Context context=getActivity(); + if(context==null) + return; + try{ + MediaMetadataRetriever mmr=new MediaMetadataRetriever(); + mmr.setDataSource(context, uri); + Bitmap frame=mmr.getFrameAtTime(3_000_000); + mmr.release(); + int size=Math.max(frame.getWidth(), frame.getHeight()); + int maxSize=V.dp(250); + if(size>maxSize){ + float factor=maxSize/(float)size; + frame=Bitmap.createScaledBitmap(frame, Math.round(frame.getWidth()*factor), Math.round(frame.getHeight()*factor), true); + } + Bitmap finalFrame=frame; + target.post(()->target.setImageBitmap(finalFrame)); + }catch(Exception x){ + Log.w(TAG, "loadVideoThumbIntoView: error getting video frame", x); + } + }); + } + @Override public CharSequence getTitle(){ return getString(R.string.new_post); @@ -1253,14 +1402,75 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr public Attachment serverAttachment; public Uri uri; public transient UploadAttachment uploadRequest; + public transient GetAttachmentByID processingPollingRequest; public String description; + public String mimeType; + public AttachmentUploadState state=AttachmentUploadState.QUEUED; public transient View view; public transient ProgressBar progressBar; public transient TextView descriptionView; public transient View overlay; public transient View infoBar; - public transient Button retryButton; + public transient ImageButton retryButton; + public transient ObjectAnimator progressBarAnimator; + public transient Runnable processingPollingRunnable; + public transient ImageView imageView; + public transient TextView uploadStateTitle, uploadStateText; + public transient TransferSpeedTracker speedTracker=new TransferSpeedTracker(); + + public void cancelUpload(){ + switch(state){ + case UPLOADING -> { + if(uploadRequest!=null){ + uploadRequest.cancel(); + uploadRequest=null; + } + } + case PROCESSING -> { + if(processingPollingRunnable!=null){ + UiUtils.removeCallbacks(processingPollingRunnable); + processingPollingRunnable=null; + } + if(processingPollingRequest!=null){ + processingPollingRequest.cancel(); + processingPollingRequest=null; + } + } + default -> throw new IllegalStateException("Unexpected state "+state); + } + } + + public boolean isUploadingOrProcessing(){ + return state==AttachmentUploadState.UPLOADING || state==AttachmentUploadState.PROCESSING; + } + + public void setOverlayVisible(boolean visible, boolean animated){ + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.S){ + if(visible){ + imageView.setRenderEffect(RenderEffect.createBlurEffect(V.dp(16), V.dp(16), Shader.TileMode.REPEAT)); + }else{ + imageView.setRenderEffect(null); + } + } + int infoBarVis=visible ? View.GONE : View.VISIBLE; + int overlayVis=visible ? View.VISIBLE : View.GONE; + if(animated){ + V.setVisibilityAnimated(infoBar, infoBarVis); + V.setVisibilityAnimated(overlay, overlayVis); + }else{ + infoBar.setVisibility(infoBarVis); + overlay.setVisibility(overlayVis); + } + } + } + + enum AttachmentUploadState{ + QUEUED, + UPLOADING, + PROCESSING, + ERROR, + DONE } private static class DraftPollOption{ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/TransferSpeedTracker.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/TransferSpeedTracker.java new file mode 100644 index 00000000..a3525aa7 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/TransferSpeedTracker.java @@ -0,0 +1,51 @@ +package org.joinmastodon.android.ui.utils; + +import android.os.SystemClock; + +public class TransferSpeedTracker{ + private final double SMOOTHING_FACTOR=0.05; + + private long lastKnownPos; + private long lastKnownPosTime; + private double lastSpeed; + private double averageSpeed; + private long totalBytes; + + public void addSample(long position){ + if(lastKnownPosTime==0){ + lastKnownPosTime=SystemClock.uptimeMillis(); + lastKnownPos=position; + }else{ + long time=SystemClock.uptimeMillis(); + lastSpeed=(position-lastKnownPos)/((double)(time-lastKnownPosTime)/1000.0); + lastKnownPos=position; + lastKnownPosTime=time; + } + } + + public double getLastSpeed(){ + return lastSpeed; + } + + public double getAverageSpeed(){ + return averageSpeed; + } + + public long updateAndGetETA(){ // must be called at a constant interval + if(averageSpeed==0.0) + averageSpeed=lastSpeed; + else + averageSpeed=SMOOTHING_FACTOR*lastSpeed+(1.0-SMOOTHING_FACTOR)*averageSpeed; + return Math.round((totalBytes-lastKnownPos)/averageSpeed); + } + + public void setTotalBytes(long totalBytes){ + this.totalBytes=totalBytes; + } + + public void reset(){ + lastKnownPos=lastKnownPosTime=0; + lastSpeed=averageSpeed=0.0; + totalBytes=0; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java index 6a572102..f9646937 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java @@ -201,6 +201,14 @@ public class UiUtils{ mainHandler.post(runnable); } + public static void runOnUiThread(Runnable runnable, long delay){ + mainHandler.postDelayed(runnable, delay); + } + + public static void removeCallbacks(Runnable runnable){ + mainHandler.removeCallbacks(runnable); + } + /** Linear interpolation between {@code startValue} and {@code endValue} by {@code fraction}. */ public static int lerp(int startValue, int endValue, float fraction) { return startValue + Math.round(fraction * (endValue - startValue)); @@ -218,6 +226,18 @@ public class UiUtils{ return uri.getLastPathSegment(); } + public static String formatFileSize(Context context, long size, boolean atLeastKB){ + if(size<1024 && !atLeastKB){ + return context.getString(R.string.file_size_bytes, size); + }else if(size<1024*1024){ + return context.getString(R.string.file_size_kb, size/1024.0); + }else if(size<1024*1024*1024){ + return context.getString(R.string.file_size_mb, size/(1024.0*1024.0)); + }else{ + return context.getString(R.string.file_size_gb, size/(1024.0*1024.0*1024.0)); + } + } + public static MediaType getFileMediaType(File file){ String name=file.getName(); return MediaType.parse(MimeTypeMap.getSingleton().getMimeTypeFromExtension(name.substring(name.lastIndexOf('.')+1))); diff --git a/mastodon/src/main/res/drawable/bg_upload_progress.xml b/mastodon/src/main/res/drawable/bg_upload_progress.xml new file mode 100644 index 00000000..d1fa18cd --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_upload_progress.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_clockwise_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_clockwise_24_filled.xml new file mode 100644 index 00000000..734179fe --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_clockwise_24_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_dismiss_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_dismiss_24_filled.xml new file mode 100644 index 00000000..0a846da3 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_dismiss_24_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/upload_progress.xml b/mastodon/src/main/res/drawable/upload_progress.xml index 522c0663..2e6a105f 100644 --- a/mastodon/src/main/res/drawable/upload_progress.xml +++ b/mastodon/src/main/res/drawable/upload_progress.xml @@ -6,7 +6,7 @@ android:shape="ring" android:thickness="4dp" android:useLevel="true"> - + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/compose_media_thumb.xml b/mastodon/src/main/res/layout/compose_media_thumb.xml index bbcfea4f..a3432b1f 100644 --- a/mastodon/src/main/res/layout/compose_media_thumb.xml +++ b/mastodon/src/main/res/layout/compose_media_thumb.xml @@ -65,30 +65,68 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="#cc000000" - android:backgroundTint="?colorWindowBackground" android:padding="8dp" android:clipToPadding="false" tools:visibility="visible" android:visibility="gone"> - - -