Compose design + media upload

This commit is contained in:
Grishka
2022-02-04 13:50:19 +03:00
parent 20d3a62747
commit cc06715aa6
24 changed files with 668 additions and 85 deletions

View File

@@ -0,0 +1,72 @@
package org.joinmastodon.android.api;
import android.database.Cursor;
import android.net.Uri;
import android.os.SystemClock;
import android.provider.OpenableColumns;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.io.IOException;
import okhttp3.MediaType;
import okhttp3.RequestBody;
import okio.Buffer;
import okio.BufferedSink;
import okio.ForwardingSink;
import okio.Okio;
import okio.Sink;
import okio.Source;
public class ContentUriRequestBody extends RequestBody{
private final Uri uri;
private final long length;
private ProgressListener progressListener;
public ContentUriRequestBody(Uri uri, ProgressListener progressListener){
this.uri=uri;
this.progressListener=progressListener;
try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.SIZE}, null, null, null)){
cursor.moveToFirst();
length=cursor.getInt(0);
}
}
@Override
public MediaType contentType(){
return MediaType.get(MastodonApp.context.getContentResolver().getType(uri));
}
@Override
public long contentLength() throws IOException{
return length;
}
@Override
public void writeTo(BufferedSink sink) throws IOException{
try(Source source=Okio.source(MastodonApp.context.getContentResolver().openInputStream(uri))){
BufferedSink wrappedSink=Okio.buffer(new CountingSink(sink));
wrappedSink.writeAll(source);
wrappedSink.flush();
}
}
private class CountingSink extends ForwardingSink{
private long bytesWritten=0;
private long lastCallbackTime;
public CountingSink(Sink delegate){
super(delegate);
}
@Override
public void write(Buffer source, long byteCount) throws IOException{
super.write(source, byteCount);
bytesWritten+=byteCount;
if(SystemClock.uptimeMillis()-lastCallbackTime>=100L || bytesWritten==length){
lastCallbackTime=SystemClock.uptimeMillis();
UiUtils.runOnUiThread(()->progressListener.onProgress(bytesWritten, length));
}
}
}
}

View File

@@ -0,0 +1,5 @@
package org.joinmastodon.android.api;
public interface ProgressListener{
void onProgress(long transferred, long total);
}

View File

@@ -0,0 +1,44 @@
package org.joinmastodon.android.api.requests.statuses;
import android.database.Cursor;
import android.net.Uri;
import android.provider.OpenableColumns;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.api.ContentUriRequestBody;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.ProgressListener;
import org.joinmastodon.android.model.Attachment;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
public class UploadAttachment extends MastodonAPIRequest<Attachment>{
private Uri uri;
private ProgressListener progressListener;
public UploadAttachment(Uri uri){
super(HttpMethod.POST, "/media", Attachment.class);
this.uri=uri;
}
public UploadAttachment setProgressListener(ProgressListener progressListener){
this.progressListener=progressListener;
return this;
}
@Override
public RequestBody getRequestBody(){
String fileName;
try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null)){
cursor.moveToFirst();
fileName=cursor.getString(0);
}
if(fileName==null)
fileName=uri.getLastPathSegment();
return new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("file", fileName, new ContentUriRequestBody(uri, progressListener))
.build();
}
}

View File

@@ -2,12 +2,19 @@ package org.joinmastodon.android.fragments;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.ProgressDialog;
import android.content.ClipData;
import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.Outline;
import android.icu.text.BreakIterator;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.Log;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
@@ -16,9 +23,13 @@ import android.view.View;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.TextView;
import com.twitter.twittertext.Regex;
@@ -26,31 +37,40 @@ import com.twitter.twittertext.TwitterTextEmojiRegex;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.ProgressListener;
import org.joinmastodon.android.api.requests.statuses.CreateStatus;
import org.joinmastodon.android.api.requests.statuses.UploadAttachment;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.EmojiCategory;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.CustomEmojiPopupKeyboard;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.PopupKeyboard;
import org.joinmastodon.android.ui.views.SizeListenerLinearLayout;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.fragments.OnBackPressedListener;
import me.grishka.appkit.fragments.ToolbarFragment;
import me.grishka.appkit.imageloader.ViewImageLoader;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
public class ComposeFragment extends ToolbarFragment{
public class ComposeFragment extends ToolbarFragment implements OnBackPressedListener{
private static final int MEDIA_RESULT=717;
private static final Pattern MENTION_PATTERN=Pattern.compile("(^|[^\\/\\w])@(([a-z0-9_]+)@[a-z0-9\\.\\-]+[a-z0-9]+)", Pattern.CASE_INSENSITIVE);
@@ -81,10 +101,14 @@ public class ComposeFragment extends ToolbarFragment{
private EditText mainEditText;
private TextView charCounter;
private String accountID;
private int charCount, charLimit;
private int charCount, charLimit, trimmedCharCount;
private MenuItem publishButton;
private ImageButton emojiBtn;
private Button publishButton;
private ImageButton mediaBtn, pollBtn, emojiBtn, spoilerBtn, visibilityBtn;
private LinearLayout attachmentsView;
private ArrayList<DraftMediaAttachment> queuedAttachments=new ArrayList<>(), failedAttachments=new ArrayList<>(), attachments=new ArrayList<>();
private DraftMediaAttachment uploadingAttachment;
private List<EmojiCategory> customEmojis;
private CustomEmojiPopupKeyboard emojiKeyboard;
@@ -127,7 +151,13 @@ public class ComposeFragment extends ToolbarFragment{
selfAvatar.setOutlineProvider(roundCornersOutline);
selfAvatar.setClipToOutline(true);
mediaBtn=view.findViewById(R.id.btn_media);
pollBtn=view.findViewById(R.id.btn_poll);
emojiBtn=view.findViewById(R.id.btn_emoji);
spoilerBtn=view.findViewById(R.id.btn_spoiler);
visibilityBtn=view.findViewById(R.id.btn_visibility);
mediaBtn.setOnClickListener(v->openFilePicker());
emojiBtn.setOnClickListener(v->emojiKeyboard.toggleKeyboardPopup(mainEditText));
emojiKeyboard.setOnIconChangedListener(new PopupKeyboard.OnIconChangeListener(){
@Override
@@ -138,6 +168,9 @@ public class ComposeFragment extends ToolbarFragment{
contentView=(SizeListenerLinearLayout) view;
contentView.addView(emojiKeyboard.getView());
emojiKeyboard.getView().setElevation(V.dp(2));
attachmentsView=view.findViewById(R.id.attachments);
return view;
}
@@ -173,35 +206,26 @@ public class ComposeFragment extends ToolbarFragment{
updateCharCounter(s);
}
});
updateToolbar();
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
publishButton=menu.add("TOOT!");
publishButton.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
publishButton=new Button(getActivity());
publishButton.setText(R.string.publish);
publishButton.setOnClickListener(this::onPublishClick);
FrameLayout wrap=new FrameLayout(getActivity());
wrap.addView(publishButton, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP|Gravity.LEFT));
wrap.setPadding(V.dp(16), V.dp(4), V.dp(16), V.dp(8));
wrap.setClipToPadding(false);
MenuItem item=menu.add(R.string.publish);
item.setActionView(wrap);
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
updatePublishButtonState();
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
String text=mainEditText.getText().toString();
CreateStatus.Request req=new CreateStatus.Request();
req.status=text;
String uuid=UUID.randomUUID().toString();
new CreateStatus(req, uuid)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Status result){
Nav.finish(ComposeFragment.this);
E.post(new StatusCreatedEvent(result));
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.exec(accountID);
return true;
}
@@ -209,6 +233,7 @@ public class ComposeFragment extends ToolbarFragment{
public void onConfigurationChanged(Configuration newConfig){
super.onConfigurationChanged(newConfig);
emojiKeyboard.onConfigurationChanged();
updateToolbar();
}
@SuppressLint("NewApi")
@@ -225,14 +250,200 @@ public class ComposeFragment extends ToolbarFragment{
}
charCounter.setText(String.valueOf(charLimit-charCount));
trimmedCharCount=text.toString().trim().length();
updatePublishButtonState();
}
private void updatePublishButtonState(){
publishButton.setEnabled(charCount>0 && charCount<=charLimit);
publishButton.setEnabled((trimmedCharCount>0 || !attachments.isEmpty()) && charCount<=charLimit && uploadingAttachment==null && failedAttachments.isEmpty() && queuedAttachments.isEmpty());
}
private void onCustomEmojiClick(Emoji emoji){
mainEditText.getText().replace(mainEditText.getSelectionStart(), mainEditText.getSelectionEnd(), ':'+emoji.shortcode+':');
}
private void updateToolbar(){
getToolbar().setNavigationIcon(R.drawable.ic_fluent_dismiss_24_regular);
}
private void onPublishClick(View v){
publish();
}
private void publish(){
String text=mainEditText.getText().toString();
CreateStatus.Request req=new CreateStatus.Request();
req.status=text;
if(!attachments.isEmpty()){
req.mediaIds=attachments.stream().map(a->a.serverAttachment.id).collect(Collectors.toList());
}
String uuid=UUID.randomUUID().toString();
ProgressDialog progress=new ProgressDialog(getActivity());
progress.setMessage(getString(R.string.publishing));
progress.setCancelable(false);
progress.show();
new CreateStatus(req, uuid)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Status result){
progress.dismiss();
Nav.finish(ComposeFragment.this);
E.post(new StatusCreatedEvent(result));
}
@Override
public void onError(ErrorResponse error){
progress.dismiss();
error.showToast(getActivity());
}
})
.exec(accountID);
}
private boolean hasDraft(){
return mainEditText.length()>0;
}
@Override
public boolean onBackPressed(){
if(emojiKeyboard.isVisible()){
emojiKeyboard.hide();
return true;
}
if(hasDraft()){
confirmDiscardDraftAndFinish();
return true;
}
return false;
}
@Override
public void onToolbarNavigationClick(){
if(hasDraft()){
confirmDiscardDraftAndFinish();
}else{
super.onToolbarNavigationClick();
}
}
private void confirmDiscardDraftAndFinish(){
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.discard_draft)
.setPositiveButton(R.string.discard, (dialog, which)->Nav.finish(this))
.setNegativeButton(R.string.cancel, null)
.show();
}
private void openFilePicker(){
Intent intent=new Intent(Intent.ACTION_GET_CONTENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{"image/*", "video/*"});
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
startActivityForResult(intent, MEDIA_RESULT);
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data){
if(requestCode==MEDIA_RESULT && resultCode==Activity.RESULT_OK){
Uri single=data.getData();
if(single!=null){
addMediaAttachment(single);
}else{
ClipData clipData=data.getClipData();
for(int i=0;i<clipData.getItemCount();i++){
addMediaAttachment(clipData.getItemAt(i).getUri());
}
}
}
}
private void addMediaAttachment(Uri uri){
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(uri, V.dp(250), V.dp(250)));
attachmentsView.addView(thumb);
DraftMediaAttachment draft=new DraftMediaAttachment();
draft.uri=uri;
draft.view=thumb;
draft.progressBar=thumb.findViewById(R.id.progress);
Button btn=thumb.findViewById(R.id.remove_btn);
btn.setTag(draft);
btn.setOnClickListener(this::onRemoveMediaAttachmentClick);
if(uploadingAttachment==null){
uploadMediaAttachment(draft);
}else{
queuedAttachments.add(draft);
}
updatePublishButtonState();
}
private void uploadMediaAttachment(DraftMediaAttachment attachment){
if(uploadingAttachment!=null)
throw new IllegalStateException("there is already an attachment being uploaded");
uploadingAttachment=attachment;
attachment.uploadRequest=(UploadAttachment) new UploadAttachment(attachment.uri)
.setProgressListener(new ProgressListener(){
@Override
public void onProgress(long transferred, long total){
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);
}
})
.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();
}
@Override
public void onError(ErrorResponse error){
attachment.uploadRequest=null;
uploadingAttachment=null;
failedAttachments.add(attachment);
error.showToast(getActivity());
// TODO show the error state in the attachment view
if(!queuedAttachments.isEmpty())
uploadMediaAttachment(queuedAttachments.remove(0));
}
})
.exec(accountID);
}
private void onRemoveMediaAttachmentClick(View v){
DraftMediaAttachment att=(DraftMediaAttachment) v.getTag();
if(att==uploadingAttachment){
att.uploadRequest.cancel();
if(!queuedAttachments.isEmpty())
uploadMediaAttachment(queuedAttachments.remove(0));
}else{
attachments.remove(att);
queuedAttachments.remove(att);
failedAttachments.remove(att);
}
attachmentsView.removeView(att.view);
updatePublishButtonState();
}
private static class DraftMediaAttachment{
public Attachment serverAttachment;
public Uri uri;
public UploadAttachment uploadRequest;
public View view;
public ProgressBar progressBar;
}
}

View File

@@ -0,0 +1,31 @@
package org.joinmastodon.android.ui;
import android.app.AlertDialog;
import android.content.Context;
import android.view.View;
import android.widget.Button;
import me.grishka.appkit.utils.V;
public class M3AlertDialogBuilder extends AlertDialog.Builder{
public M3AlertDialogBuilder(Context context){
super(context);
}
public M3AlertDialogBuilder(Context context, int themeResId){
super(context, themeResId);
}
@Override
public AlertDialog create(){
AlertDialog alert=super.create();
alert.create();
Button btn=alert.getButton(AlertDialog.BUTTON_POSITIVE);
if(btn!=null){
View buttonBar=(View) btn.getParent();
buttonBar.setPadding(V.dp(16), V.dp(24), V.dp(16), V.dp(24));
((View)buttonBar.getParent()).setPadding(0, 0, 0, 0);
}
return alert;
}
}

View File

@@ -4,6 +4,8 @@ import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.widget.TextView;
@@ -15,6 +17,8 @@ import androidx.annotation.ColorRes;
import androidx.browser.customtabs.CustomTabsIntent;
public class UiUtils{
private static Handler mainHandler=new Handler(Looper.getMainLooper());
private UiUtils(){}
public static void launchWebBrowser(Context context, String url){
@@ -56,4 +60,8 @@ public class UiUtils{
}
textView.setCompoundDrawablesRelative(drawables[0], drawables[1], drawables[2], drawables[3]);
}
public static void runOnUiThread(Runnable runnable){
mainHandler.post(runnable);
}
}