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="wrap_content">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml
index 557ac476..0b7a9603 100644
--- a/app/src/main/res/layout/activity_login.xml
+++ b/app/src/main/res/layout/activity_login.xml
@@ -30,17 +30,15 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName"
- android:text="Domain"
+ android:contentDescription="@string/description_domain"
android:ems="10"
android:id="@+id/edit_text_domain" />
+ android:id="@+id/button_login" />
@@ -127,7 +127,7 @@
@@ -141,14 +141,14 @@
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index dc9fb7a7..a7e7dde9 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -6,4 +6,5 @@
#4F4F4F
#000000
#303030
+ #DFDFDF
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index b1cfad05..7f5b7c10 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -6,4 +6,9 @@
8dp
5dp
4dp
+ 96dp
+ 8dp
+ 16dp
+ 48dp
+ 8dp
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c316faff..5d523ffb 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -42,6 +42,12 @@
Notifications could not be fetched.
The toot is too long!
The toot failed to be sent.
+ The file must be less than 4MB.
+ That type of file is not able to be uploaded.
+ That file could not be opened.
+ Permission to read media is required to upload it.
+ Images and videos cannot both be attached to the same toot.
+ The media could not be uploaded.
Home
Notifications
@@ -57,10 +63,17 @@
%s followed you
Compose
+ Log In
Log Out
Follow
Block
Delete
+ TOOT
+ Retry
+ Mark Sensitive
+
+ Domain
+ What\'s Happening?
Public
Private