diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/ContentUriRequestBody.java b/mastodon/src/main/java/org/joinmastodon/android/api/ContentUriRequestBody.java new file mode 100644 index 00000000..3cdf9a78 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/ContentUriRequestBody.java @@ -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)); + } + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/ProgressListener.java b/mastodon/src/main/java/org/joinmastodon/android/api/ProgressListener.java new file mode 100644 index 00000000..e66c5c0a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/ProgressListener.java @@ -0,0 +1,5 @@ +package org.joinmastodon.android.api; + +public interface ProgressListener{ + void onProgress(long transferred, long total); +} 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 new file mode 100644 index 00000000..a534652c --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/UploadAttachment.java @@ -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{ + 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(); + } +} 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 624df127..6c6cba1c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java @@ -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 queuedAttachments=new ArrayList<>(), failedAttachments=new ArrayList<>(), attachments=new ArrayList<>(); + private DraftMediaAttachment uploadingAttachment; private List 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=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; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/M3AlertDialogBuilder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/M3AlertDialogBuilder.java new file mode 100644 index 00000000..5efe3c4c --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/M3AlertDialogBuilder.java @@ -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; + } +} 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 14f46f85..f8e6c414 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 @@ -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); + } } diff --git a/mastodon/src/main/res/color/button_bg.xml b/mastodon/src/main/res/color/button_bg.xml new file mode 100644 index 00000000..45dc00fd --- /dev/null +++ b/mastodon/src/main/res/color/button_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_alert.xml b/mastodon/src/main/res/drawable/bg_alert.xml new file mode 100644 index 00000000..d098c58b --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_alert.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_alert_button.xml b/mastodon/src/main/res/drawable/bg_alert_button.xml new file mode 100644 index 00000000..1f3f9743 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_alert_button.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_button.xml b/mastodon/src/main/res/drawable/bg_button.xml new file mode 100644 index 00000000..2c1d1374 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_button.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_chat_warning_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_chat_warning_24_filled.xml new file mode 100644 index 00000000..78484797 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_chat_warning_24_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_chat_warning_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_chat_warning_24_regular.xml new file mode 100644 index 00000000..8baf67ea --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_chat_warning_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_chat_warning_24_selector.xml b/mastodon/src/main/res/drawable/ic_fluent_chat_warning_24_selector.xml new file mode 100644 index 00000000..10810ca2 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_chat_warning_24_selector.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_dismiss_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_dismiss_24_regular.xml new file mode 100644 index 00000000..7f588bc6 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_dismiss_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_image_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_image_24_regular.xml new file mode 100644 index 00000000..b6048c5a --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_image_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_people_community_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_people_community_24_regular.xml new file mode 100644 index 00000000..7ca800f5 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_people_community_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_poll_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_poll_24_filled.xml new file mode 100644 index 00000000..34070a60 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_poll_24_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_poll_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_poll_24_regular.xml new file mode 100644 index 00000000..42790e62 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_poll_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_poll_24_selector.xml b/mastodon/src/main/res/drawable/ic_fluent_poll_24_selector.xml new file mode 100644 index 00000000..02a28cc3 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_poll_24_selector.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/mastodon/src/main/res/layout/compose_media_thumb.xml b/mastodon/src/main/res/layout/compose_media_thumb.xml new file mode 100644 index 00000000..5812a015 --- /dev/null +++ b/mastodon/src/main/res/layout/compose_media_thumb.xml @@ -0,0 +1,26 @@ + + + + + +