From 6b684bceffcafc81c4d2a4fb5633df714648f7bb Mon Sep 17 00:00:00 2001 From: Vavassor Date: Mon, 16 Jan 2017 13:15:42 -0500 Subject: [PATCH] Attaching media to toots is now possible. Images over the upload limit are automatically downsized, videos are not. --- app/src/main/AndroidManifest.xml | 1 + .../keylesspalace/tusky/ComposeActivity.java | 509 +++++++++++++++++- .../keylesspalace/tusky/CountUpDownLatch.java | 25 + .../tusky/DownsizeImageTask.java | 78 +++ .../keylesspalace/tusky/MultipartRequest.java | 102 ++++ .../keylesspalace/tusky/TimelineAdapter.java | 11 +- .../keylesspalace/tusky/VolleySingleton.java | 4 + app/src/main/res/drawable/ic_media.xml | 31 ++ .../main/res/drawable/ic_media_disabled.xml | 11 + .../res/drawable/media_preview_unloaded.xml | 14 + app/src/main/res/drawable/media_selector.xml | 5 + app/src/main/res/layout/activity_compose.xml | 64 ++- app/src/main/res/layout/activity_login.xml | 8 +- app/src/main/res/layout/item_status.xml | 8 +- app/src/main/res/values/colors.xml | 1 + app/src/main/res/values/dimens.xml | 5 + app/src/main/res/values/strings.xml | 13 + 17 files changed, 865 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/CountUpDownLatch.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/DownsizeImageTask.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/MultipartRequest.java create mode 100644 app/src/main/res/drawable/ic_media.xml create mode 100644 app/src/main/res/drawable/ic_media_disabled.xml create mode 100644 app/src/main/res/drawable/media_preview_unloaded.xml create mode 100644 app/src/main/res/drawable/media_selector.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 46fb5d23..dd187ac7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ package="com.keylesspalace.tusky"> + mediaQueued; + private CountUpDownLatch waitForMediaLatch; + + private static class QueuedMedia { + public enum Type { + IMAGE, + VIDEO + } + + public enum ReadyStage { + DOWNSIZING, + UPLOADING, + } + + private Type type; + private ImageView preview; + private Uri uri; + private String id; + private ReadyStage readyStage; + private byte[] content; + + public QueuedMedia(Type type, Uri uri, ImageView preview) { + this.type = type; + this.uri = uri; + this.preview = preview; + } + + public Type getType() { + return type; + } + + public ImageView getPreview() { + return preview; + } + + public Uri getUri() { + return uri; + } + + public String getId() { + return id; + } + + public byte[] getContent() { + return content; + } + + public ReadyStage getReadyStage() { + return readyStage; + } + + public void setId(String id) { + this.id = id; + } + + public void setReadyStage(ReadyStage readyStage) { + this.readyStage = readyStage; + } + + public void setContent(byte[] content) { + this.content = content; + } + } + + private void doErrorDialog(int descriptionId, int actionId, View.OnClickListener listener) { + Snackbar bar = Snackbar.make(findViewById(R.id.activity_compose), getString(descriptionId), + Snackbar.LENGTH_SHORT); + bar.setAction(actionId, listener); + bar.show(); + } + + private void displayTransientError(int stringId) { + Snackbar.make(findViewById(R.id.activity_compose), stringId, Snackbar.LENGTH_LONG).show(); + } private void onSendSuccess() { Toast.makeText(this, "Toot!", Toast.LENGTH_SHORT).show(); @@ -41,13 +155,21 @@ public class ComposeActivity extends AppCompatActivity { textEditor.setError(getString(R.string.error_sending_status)); } - private void sendStatus(String content, String visibility) { + private void sendStatus(String content, String visibility, boolean sensitive) { String endpoint = getString(R.string.endpoint_status); String url = "https://" + domain + endpoint; JSONObject parameters = new JSONObject(); try { parameters.put("status", content); parameters.put("visibility", visibility); + parameters.put("sensitive", sensitive); + JSONArray media_ids = new JSONArray(); + for (QueuedMedia item : mediaQueued) { + media_ids.put(item.getId()); + } + if (media_ids.length() > 0) { + parameters.put("media_ids", media_ids); + } } catch (JSONException e) { onSendFailure(e); return; @@ -74,6 +196,48 @@ public class ComposeActivity extends AppCompatActivity { VolleySingleton.getInstance(this).addToRequestQueue(request); } + private void onReadyFailure(Exception exception, final String content, + final String visibility, final boolean sensitive) { + doErrorDialog(R.string.error_media_upload_sending, R.string.action_retry, + new View.OnClickListener() { + @Override + public void onClick(View v) { + readyStatus(content, visibility, sensitive); + } + }); + } + + private void readyStatus(final String content, final String visibility, + final boolean sensitive) { + final ProgressDialog dialog = ProgressDialog.show(this, "Finishing Media Upload", + "Uploading...", true); + new AsyncTask() { + private Exception exception; + + @Override + protected Boolean doInBackground(Void... params) { + try { + waitForMediaLatch.await(); + } catch (InterruptedException e) { + exception = e; + return false; + } + return true; + } + + @Override + protected void onPostExecute(Boolean successful) { + super.onPostExecute(successful); + dialog.dismiss(); + if (successful) { + sendStatus(content, visibility, sensitive); + } else { + onReadyFailure(exception, content, visibility, sensitive); + } + } + }.execute(); + } + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -103,6 +267,10 @@ public class ComposeActivity extends AppCompatActivity { }; textEditor.addTextChangedListener(textEditorWatcher); + mediaPreviewBar = (LinearLayout) findViewById(R.id.compose_media_preview_bar); + mediaQueued = new ArrayList<>(); + waitForMediaLatch = new CountUpDownLatch(); + final RadioGroup radio = (RadioGroup) findViewById(R.id.radio_visibility); final Button sendButton = (Button) findViewById(R.id.button_send); sendButton.setOnClickListener(new View.OnClickListener() { @@ -127,11 +295,346 @@ public class ComposeActivity extends AppCompatActivity { break; } } - sendStatus(editable.toString(), visibility); + readyStatus(editable.toString(), visibility, markSensitive.isChecked()); } else { textEditor.setError(getString(R.string.error_compose_character_limit)); } } }); + + mediaPick = (ImageButton) findViewById(R.id.compose_photo_pick); + mediaPick.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onMediaPick(); + } + }); + markSensitive = (CheckBox) findViewById(R.id.compose_mark_sensitive); + markSensitive.setVisibility(View.GONE); + } + + private void onMediaPick() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && + ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, + new String[] { Manifest.permission.READ_EXTERNAL_STORAGE }, + PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE); + } else { + initiateMediaPicking(); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[], + @NonNull int[] grantResults) { + switch (requestCode) { + case PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE: { + if (grantResults.length > 0 + && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + initiateMediaPicking(); + } else { + doErrorDialog(R.string.error_media_upload_permission, R.string.action_retry, + new View.OnClickListener() { + @Override + public void onClick(View v) { + onMediaPick(); + } + }); + } + break; + } + } + } + + private void initiateMediaPicking() { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + intent.setType("image/* video/*"); + } else { + String[] mimeTypes = new String[] { "image/*", "video/*" }; + intent.setType("*/*"); + intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); + } + startActivityForResult(intent, MEDIA_PICK_RESULT); + } + + /** A replacement for View.setPaddingRelative to use under API level 16. */ + private static void setPaddingRelative(View view, int left, int top, int right, int bottom) { + view.setPadding( + view.getPaddingLeft() + left, + view.getPaddingTop() + top, + view.getPaddingRight() + right, + view.getPaddingBottom() + bottom); + } + + private void enableMediaPicking() { + mediaPick.setEnabled(true); + } + + private void disableMediaPicking() { + mediaPick.setEnabled(false); + } + + private void addMediaToQueue(QueuedMedia.Type type, Bitmap preview, Uri uri, long mediaSize) { + assert(mediaQueued.size() < Status.MAX_MEDIA_ATTACHMENTS); + final QueuedMedia item = new QueuedMedia(type, uri, new ImageView(this)); + ImageView view = item.getPreview(); + Resources resources = getResources(); + int side = resources.getDimensionPixelSize(R.dimen.compose_media_preview_side); + int margin = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin); + int marginBottom = resources.getDimensionPixelSize( + R.dimen.compose_media_preview_margin_bottom); + LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(side, side); + layoutParams.setMargins(margin, margin, margin, marginBottom); + view.setLayoutParams(layoutParams); + view.setImageBitmap(preview); + view.setScaleType(ImageView.ScaleType.CENTER_CROP); + view.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + removeMediaFromQueue(item); + } + }); + mediaPreviewBar.addView(view); + mediaQueued.add(item); + int queuedCount = mediaQueued.size(); + if (queuedCount == 1) { + /* The media preview bar is actually not inset in the EditText, it just overlays it and + * is aligned to the bottom. But, so that text doesn't get hidden under it, extra + * padding is added at the bottom of the EditText. */ + int totalHeight = side + margin + marginBottom; + setPaddingRelative(textEditor, 0, 0, 0, totalHeight); + // If there's one video in the queue it is full, so disable the button to queue more. + if (item.getType() == QueuedMedia.Type.VIDEO) { + disableMediaPicking(); + } + } else if (queuedCount >= Status.MAX_MEDIA_ATTACHMENTS) { + // Limit the total media attachments, also. + disableMediaPicking(); + } + if (queuedCount >= 1) { + markSensitive.setVisibility(View.VISIBLE); + } + waitForMediaLatch.countUp(); + if (mediaSize > STATUS_MEDIA_SIZE_LIMIT && type == QueuedMedia.Type.IMAGE) { + downsizeMedia(item); + } else { + uploadMedia(item); + } + } + + private void removeMediaFromQueue(QueuedMedia item) { + int moveBottom = mediaPreviewBar.getMeasuredHeight(); + mediaPreviewBar.removeView(item.getPreview()); + mediaQueued.remove(item); + if (mediaQueued.size() == 0) { + markSensitive.setVisibility(View.GONE); + /* If there are no image previews to show, the extra padding that was added to the + * EditText can be removed so there isn't unnecessary empty space. */ + setPaddingRelative(textEditor, 0, 0, 0, moveBottom); + } + enableMediaPicking(); + cancelReadyingMedia(item); + } + + private void downsizeMedia(final QueuedMedia item) { + item.setReadyStage(QueuedMedia.ReadyStage.DOWNSIZING); + InputStream stream; + try { + stream = getContentResolver().openInputStream(item.getUri()); + } catch (FileNotFoundException e) { + onMediaDownsizeFailure(item); + return; + } + Bitmap bitmap = BitmapFactory.decodeStream(stream); + new DownsizeImageTask(STATUS_MEDIA_SIZE_LIMIT, new DownsizeImageTask.Listener() { + @Override + public void onSuccess(List contentList) { + item.setContent(contentList.get(0)); + uploadMedia(item); + } + + @Override + public void onFailure() { + onMediaDownsizeFailure(item); + } + }).execute(bitmap); + } + + private void onMediaDownsizeFailure(QueuedMedia item) { + displayTransientError(R.string.error_media_upload_size); + removeMediaFromQueue(item); + } + + private static String randomAlphanumericString(int count) { + char[] chars = new char[count]; + Random random = new Random(); + final String POSSIBLE_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + for (int i = 0; i < count; i++) { + chars[i] = POSSIBLE_CHARS.charAt(random.nextInt(POSSIBLE_CHARS.length())); + } + return new String(chars); + } + + @Nullable + private static byte[] inputStreamGetBytes(InputStream stream) { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + int read; + byte[] data = new byte[16384]; + try { + while ((read = stream.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, read); + } + buffer.flush(); + } catch (IOException e) { + return null; + } + return buffer.toByteArray(); + } + + private void uploadMedia(final QueuedMedia item) { + item.setReadyStage(QueuedMedia.ReadyStage.UPLOADING); + + String endpoint = getString(R.string.endpoint_media); + String url = "https://" + domain + endpoint; + + final String mimeType = getContentResolver().getType(item.uri); + MimeTypeMap map = MimeTypeMap.getSingleton(); + String fileExtension = map.getExtensionFromMimeType(mimeType); + final String filename = String.format("%s_%s_%s.%s", + getString(R.string.app_name), + String.valueOf(new Date().getTime()), + randomAlphanumericString(10), + fileExtension); + + MultipartRequest request = new MultipartRequest(Request.Method.POST, url, null, + new Response.Listener() { + @Override + public void onResponse(JSONObject response) { + try { + item.setId(response.getString("id")); + } catch (JSONException e) { + onUploadFailure(item, e); + return; + } + waitForMediaLatch.countDown(); + } + }, new Response.ErrorListener() { + @Override + public void onErrorResponse(VolleyError error) { + onUploadFailure(item, error); + } + }) { + @Override + public Map getHeaders() throws AuthFailureError { + Map headers = new HashMap<>(); + headers.put("Authorization", "Bearer " + accessToken); + return headers; + } + + @Override + public DataItem getData() { + byte[] content = item.getContent(); + if (content == null) { + InputStream stream; + try { + stream = getContentResolver().openInputStream(item.getUri()); + } catch (FileNotFoundException e) { + return null; + } + content = inputStreamGetBytes(stream); + if (content == null) { + return null; + } + } + DataItem data = new DataItem(); + data.name = "file"; + data.filename = filename; + data.mimeType = mimeType; + data.content = content; + return data; + } + }; + request.addMarker("media_" + item.getUri().toString()); + VolleySingleton.getInstance(this).addToRequestQueue(request); + } + + private void onUploadFailure(QueuedMedia item, @Nullable Exception exception) { + displayTransientError(R.string.error_media_upload_sending); + removeMediaFromQueue(item); + } + + private void cancelReadyingMedia(QueuedMedia item) { + if (item.getReadyStage() == QueuedMedia.ReadyStage.UPLOADING) { + VolleySingleton.getInstance(this).cancelRequest("media_" + item.getUri().toString()); + } + waitForMediaLatch.countDown(); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == MEDIA_PICK_RESULT && resultCode == RESULT_OK && data != null) { + Uri uri = data.getData(); + ContentResolver contentResolver = getContentResolver(); + Cursor cursor = getContentResolver().query(uri, null, null, null, null); + int sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE); + cursor.moveToFirst(); + long mediaSize = cursor.getLong(sizeIndex); + cursor.close(); + String mimeType = contentResolver.getType(uri); + if (mimeType != null) { + String topLevelType = mimeType.substring(0, mimeType.indexOf('/')); + switch (topLevelType) { + case "video": { + if (mediaSize > STATUS_MEDIA_SIZE_LIMIT) { + displayTransientError(R.string.error_media_upload_size); + return; + } + if (mediaQueued.size() > 0 + && mediaQueued.get(0).getType() == QueuedMedia.Type.IMAGE) { + displayTransientError(R.string.error_media_upload_image_or_video); + return; + } + MediaMetadataRetriever retriever = new MediaMetadataRetriever(); + retriever.setDataSource(this, uri); + Bitmap source = retriever.getFrameAtTime(); + Bitmap bitmap = ThumbnailUtils.extractThumbnail(source, 96, 96); + source.recycle(); + addMediaToQueue(QueuedMedia.Type.VIDEO, bitmap, uri, mediaSize); + break; + } + case "image": { + InputStream stream; + try { + stream = contentResolver.openInputStream(uri); + } catch (FileNotFoundException e) { + displayTransientError(R.string.error_media_upload_opening); + return; + } + Bitmap source = BitmapFactory.decodeStream(stream); + Bitmap bitmap = ThumbnailUtils.extractThumbnail(source, 96, 96); + source.recycle(); + try { + stream.close(); + } catch (IOException e) { + bitmap.recycle(); + displayTransientError(R.string.error_media_upload_opening); + return; + } + addMediaToQueue(QueuedMedia.Type.IMAGE, bitmap, uri, mediaSize); + break; + } + default: { + displayTransientError(R.string.error_media_upload_type); + break; + } + } + } else { + displayTransientError(R.string.error_media_upload_type); + } + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/CountUpDownLatch.java b/app/src/main/java/com/keylesspalace/tusky/CountUpDownLatch.java new file mode 100644 index 00000000..07174ade --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/CountUpDownLatch.java @@ -0,0 +1,25 @@ +package com.keylesspalace.tusky; + +public class CountUpDownLatch { + private int count; + + public CountUpDownLatch() { + this.count = 0; + } + + public synchronized void countDown() { + count--; + notifyAll(); + } + + public synchronized void countUp() { + count++; + notifyAll(); + } + + public synchronized void await() throws InterruptedException { + while (count != 0) { + wait(); + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/DownsizeImageTask.java b/app/src/main/java/com/keylesspalace/tusky/DownsizeImageTask.java new file mode 100644 index 00000000..0161513e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/DownsizeImageTask.java @@ -0,0 +1,78 @@ +package com.keylesspalace.tusky; + +import android.graphics.Bitmap; +import android.os.AsyncTask; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.List; + +public class DownsizeImageTask extends AsyncTask { + private Listener listener; + private int sizeLimit; + private List resultList; + + public DownsizeImageTask(int sizeLimit, Listener listener) { + this.listener = listener; + this.sizeLimit = sizeLimit; + } + + public static Bitmap scaleDown(Bitmap source, float maxImageSize, boolean filter) { + float ratio = Math.min(maxImageSize / source.getWidth(), maxImageSize / source.getHeight()); + int width = Math.round(ratio * source.getWidth()); + int height = Math.round(ratio * source.getHeight()); + return Bitmap.createScaledBitmap(source, width, height, filter); + } + + @Override + protected Boolean doInBackground(Bitmap... bitmaps) { + final int count = bitmaps.length; + resultList = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + /* Unfortunately, there isn't a determined worst case compression ratio for image + * formats. So, the only way to tell if they're too big is to compress them and + * test, and keep trying at smaller sizes. The initial estimate should be good for + * many cases, so it should only iterate once, but the loop is used to be absolutely + * sure it gets downsized to below the limit. */ + int iterations = 0; + int scaledImageSize = 4096; + do { + stream.reset(); + Bitmap bitmap = scaleDown(bitmaps[i], scaledImageSize, true); + Bitmap.CompressFormat format; + /* It's not likely the user will give transparent images over the upload limit, but + * if they do, make sure the transparency is retained. */ + if (!bitmap.hasAlpha()) { + format = Bitmap.CompressFormat.JPEG; + } else { + format = Bitmap.CompressFormat.PNG; + } + bitmap.compress(format, 75, stream); + scaledImageSize /= 2; + iterations++; + } while (stream.size() > sizeLimit); + assert(iterations < 3); + resultList.add(stream.toByteArray()); + if (isCancelled()) { + return false; + } + } + return true; + } + + @Override + protected void onPostExecute(Boolean successful) { + if (successful) { + listener.onSuccess(resultList); + } else { + listener.onFailure(); + } + super.onPostExecute(successful); + } + + public interface Listener { + void onSuccess(List contentList); + void onFailure(); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/MultipartRequest.java b/app/src/main/java/com/keylesspalace/tusky/MultipartRequest.java new file mode 100644 index 00000000..1055a48b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/MultipartRequest.java @@ -0,0 +1,102 @@ +package com.keylesspalace.tusky; + +import com.android.volley.NetworkResponse; +import com.android.volley.ParseError; +import com.android.volley.Request; +import com.android.volley.Response; +import com.android.volley.toolbox.HttpHeaderParser; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.UnsupportedEncodingException; + +public class MultipartRequest extends Request { + private static final String CHARSET = "utf-8"; + private final String boundary = "something-" + System.currentTimeMillis(); + + private JSONObject parameters; + private Response.Listener listener; + + public MultipartRequest(int method, String url, JSONObject parameters, + Response.Listener listener, Response.ErrorListener errorListener) { + super(method, url, errorListener); + this.parameters = parameters; + this.listener = listener; + } + + @Override + public String getBodyContentType() { + return "multipart/form-data;boundary=" + boundary; + } + + @Override + public byte[] getBody() { + ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); + DataOutputStream stream = new DataOutputStream(byteStream); + try { + // Write the JSON parameters first. + if (parameters != null) { + stream.writeBytes(String.format("--%s\r\n", boundary)); + stream.writeBytes("Content-Disposition: form-data; name=\"parameters\"\r\n"); + stream.writeBytes(String.format( + "Content-Type: application/json; charset=%s\r\n", CHARSET)); + stream.writeBytes("\r\n"); + stream.writeBytes(parameters.toString()); + } + + // Write the binary data. + DataItem data = getData(); + if (data != null) { + stream.writeBytes(String.format("--%s\r\n", boundary)); + stream.writeBytes(String.format( + "Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"\r\n", + data.name, data.filename)); + stream.writeBytes(String.format("Content-Type: %s\r\n", data.mimeType)); + stream.writeBytes(String.format("Content-Length: %s\r\n", + String.valueOf(data.content.length))); + stream.writeBytes("\r\n"); + stream.write(data.content); + } + + // Close the multipart form data. + stream.writeBytes(String.format("--%s--\r\n", boundary)); + + return byteStream.toByteArray(); + } catch (IOException e) { + e.printStackTrace(); + } + return null; + } + + @Override + protected Response parseNetworkResponse(NetworkResponse response) { + try { + String jsonString = new String(response.data, + HttpHeaderParser.parseCharset(response.headers)); + return Response.success(new JSONObject(jsonString), + HttpHeaderParser.parseCacheHeaders(response)); + } catch (JSONException|UnsupportedEncodingException e) { + return Response.error(new ParseError(e)); + } + } + + @Override + protected void deliverResponse(JSONObject response) { + listener.onResponse(response); + } + + public DataItem getData() { + return null; + } + + public static class DataItem { + public String name; + public String filename; + public String mimeType; + public byte[] content; + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java b/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java index 89e72e51..7b78ad8a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java @@ -55,9 +55,12 @@ public class TimelineAdapter extends RecyclerView.Adapter { } else { holder.setRebloggedByUsername(rebloggedByUsername); } + Status.MediaAttachment[] attachments = status.getAttachments(); boolean sensitive = status.getSensitive(); - holder.setMediaPreviews(status.getAttachments(), sensitive, listener); - if (!sensitive) { + holder.setMediaPreviews(attachments, sensitive, listener); + /* A status without attachments is sometimes still marked sensitive, so it's necessary + * to check both whether there are any attachments and if it's marked sensitive. */ + if (!sensitive || attachments.length == 0) { holder.hideSensitiveMediaWarning(); } holder.setupButtons(listener, position); @@ -144,6 +147,10 @@ public class TimelineAdapter extends RecyclerView.Adapter { mediaPreview1 = (NetworkImageView) itemView.findViewById(R.id.status_media_preview_1); mediaPreview2 = (NetworkImageView) itemView.findViewById(R.id.status_media_preview_2); mediaPreview3 = (NetworkImageView) itemView.findViewById(R.id.status_media_preview_3); + mediaPreview0.setDefaultImageResId(R.drawable.media_preview_unloaded); + mediaPreview1.setDefaultImageResId(R.drawable.media_preview_unloaded); + mediaPreview2.setDefaultImageResId(R.drawable.media_preview_unloaded); + mediaPreview3.setDefaultImageResId(R.drawable.media_preview_unloaded); sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning); } diff --git a/app/src/main/java/com/keylesspalace/tusky/VolleySingleton.java b/app/src/main/java/com/keylesspalace/tusky/VolleySingleton.java index abfbc009..51f0001a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/VolleySingleton.java +++ b/app/src/main/java/com/keylesspalace/tusky/VolleySingleton.java @@ -54,6 +54,10 @@ public class VolleySingleton { getRequestQueue().add(request); } + public void cancelRequest(String tag) { + getRequestQueue().cancelAll(tag); + } + public ImageLoader getImageLoader() { return imageLoader; } diff --git a/app/src/main/res/drawable/ic_media.xml b/app/src/main/res/drawable/ic_media.xml new file mode 100644 index 00000000..53658657 --- /dev/null +++ b/app/src/main/res/drawable/ic_media.xml @@ -0,0 +1,31 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_media_disabled.xml b/app/src/main/res/drawable/ic_media_disabled.xml new file mode 100644 index 00000000..9c1a90e9 --- /dev/null +++ b/app/src/main/res/drawable/ic_media_disabled.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/media_preview_unloaded.xml b/app/src/main/res/drawable/media_preview_unloaded.xml new file mode 100644 index 00000000..76a1e531 --- /dev/null +++ b/app/src/main/res/drawable/media_preview_unloaded.xml @@ -0,0 +1,14 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/media_selector.xml b/app/src/main/res/drawable/media_selector.xml new file mode 100644 index 00000000..d3f8b931 --- /dev/null +++ b/app/src/main/res/drawable/media_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_compose.xml b/app/src/main/res/layout/activity_compose.xml index a02f76fd..8a3693af 100644 --- a/app/src/main/res/layout/activity_compose.xml +++ b/app/src/main/res/layout/activity_compose.xml @@ -1,17 +1,59 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/activity_compose" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> - + + + + + + + + + android:layout_height="0dp" + android:layout_weight="1"> + + + + + + + + + +