Support for receiving arbitrary attachment types

// FREEBIE
This commit is contained in:
Moxie Marlinspike
2017-03-28 12:05:30 -07:00
parent c69efbffd2
commit f67eb5f9f3
60 changed files with 1251 additions and 423 deletions

View File

@@ -0,0 +1,120 @@
package org.thoughtcrime.securesms.util;
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* An input stream, which limits its data size. This stream is
* used, if the content length is unknown.
*/
public class LimitedInputStream extends FilterInputStream {
/**
* The maximum size of an item, in bytes.
*/
private long sizeMax;
/**
* The current number of bytes.
*/
private long count;
/**
* Whether this stream is already closed.
*/
private boolean closed;
/**
* Creates a new instance.
* @param pIn The input stream, which shall be limited.
* @param pSizeMax The limit; no more than this number of bytes
* shall be returned by the source stream.
*/
public LimitedInputStream(InputStream pIn, long pSizeMax) {
super(pIn);
sizeMax = pSizeMax;
}
/**
* Reads the next byte of data from this input stream. The value
* byte is returned as an <code>int</code> in the range
* <code>0</code> to <code>255</code>. If no byte is available
* because the end of the stream has been reached, the value
* <code>-1</code> is returned. This method blocks until input data
* is available, the end of the stream is detected, or an exception
* is thrown.
*
* This method
* simply performs <code>in.read()</code> and returns the result.
*
* @return the next byte of data, or <code>-1</code> if the end of the
* stream is reached.
* @exception IOException if an I/O error occurs.
* @see java.io.FilterInputStream#in
*/
public int read() throws IOException {
if (count >= sizeMax) return -1;
int res = super.read();
if (res != -1) {
count++;
}
return res;
}
/**
* Reads up to <code>len</code> bytes of data from this input stream
* into an array of bytes. If <code>len</code> is not zero, the method
* blocks until some input is available; otherwise, no
* bytes are read and <code>0</code> is returned.
*
* This method simply performs <code>in.read(b, off, len)</code>
* and returns the result.
*
* @param b the buffer into which the data is read.
* @param off The start offset in the destination array
* <code>b</code>.
* @param len the maximum number of bytes read.
* @return the total number of bytes read into the buffer, or
* <code>-1</code> if there is no more data because the end of
* the stream has been reached.
* @exception NullPointerException If <code>b</code> is <code>null</code>.
* @exception IndexOutOfBoundsException If <code>off</code> is negative,
* <code>len</code> is negative, or <code>len</code> is greater than
* <code>b.length - off</code>
* @exception IOException if an I/O error occurs.
* @see java.io.FilterInputStream#in
*/
public int read(byte[] b, int off, int len) throws IOException {
if (count >= sizeMax) return -1;
long correctLength = Math.min(len, sizeMax - count);
int res = super.read(b, off, Util.toIntExact(correctLength));
if (res > 0) {
count += res;
}
return res;
}
}

View File

@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.crypto.MasterSecret;
import org.thoughtcrime.securesms.mms.AudioSlide;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.DocumentSlide;
import org.thoughtcrime.securesms.mms.GifSlide;
import org.thoughtcrime.securesms.mms.ImageSlide;
import org.thoughtcrime.securesms.mms.MmsSlide;
@@ -82,6 +83,8 @@ public class MediaUtil {
slide = new AudioSlide(context, attachment);
} else if (isMms(attachment.getContentType())) {
slide = new MmsSlide(context, attachment);
} else {
slide = new DocumentSlide(context, attachment);
}
return slide;

View File

@@ -0,0 +1,41 @@
package org.thoughtcrime.securesms.util;
import android.os.Build;
import android.os.MemoryFile;
import android.os.ParcelFileDescriptor;
import java.io.FileDescriptor;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class MemoryFileUtil {
public static ParcelFileDescriptor getParcelFileDescriptor(MemoryFile file) throws IOException {
try {
Method method = MemoryFile.class.getDeclaredMethod("getFileDescriptor");
FileDescriptor fileDescriptor = (FileDescriptor) method.invoke(file);
Field field = fileDescriptor.getClass().getDeclaredField("descriptor");
field.setAccessible(true);
int fd = field.getInt(fileDescriptor);
if (Build.VERSION.SDK_INT >= 13) {
return ParcelFileDescriptor.adoptFd(fd);
} else {
return ParcelFileDescriptor.dup(fileDescriptor);
}
} catch (IllegalAccessException e) {
throw new IOException(e);
} catch (InvocationTargetException e) {
throw new IOException(e);
} catch (NoSuchMethodException e) {
throw new IOException(e);
} catch (NoSuchFieldException e) {
throw new IOException(e);
}
}
}

View File

@@ -2,12 +2,16 @@ package org.thoughtcrime.securesms.util;
import android.content.Context;
import android.content.DialogInterface.OnClickListener;
import android.content.Intent;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.Environment;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.design.widget.Snackbar;
import android.support.v7.app.AlertDialog;
import android.util.Log;
import android.view.View;
import android.webkit.MimeTypeMap;
import android.widget.Toast;
@@ -15,6 +19,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.crypto.MasterSecret;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import org.whispersystems.libsignal.util.Pair;
import java.io.File;
import java.io.FileOutputStream;
@@ -24,69 +29,76 @@ import java.io.OutputStream;
import java.lang.ref.WeakReference;
import java.text.SimpleDateFormat;
public class SaveAttachmentTask extends ProgressDialogAsyncTask<SaveAttachmentTask.Attachment, Void, Integer> {
public class SaveAttachmentTask extends ProgressDialogAsyncTask<SaveAttachmentTask.Attachment, Void, Pair<Integer, File>> {
private static final String TAG = SaveAttachmentTask.class.getSimpleName();
private static final int SUCCESS = 0;
private static final int FAILURE = 1;
private static final int WRITE_ACCESS_FAILURE = 2;
private final WeakReference<Context> contextReference;
private final WeakReference<Context> contextReference;
private final WeakReference<MasterSecret> masterSecretReference;
private final WeakReference<View> view;
private final int attachmentCount;
public SaveAttachmentTask(Context context, MasterSecret masterSecret) {
this(context, masterSecret, 1);
public SaveAttachmentTask(Context context, MasterSecret masterSecret, View view) {
this(context, masterSecret, view, 1);
}
public SaveAttachmentTask(Context context, MasterSecret masterSecret, int count) {
public SaveAttachmentTask(Context context, MasterSecret masterSecret, View view, int count) {
super(context,
context.getResources().getQuantityString(R.plurals.ConversationFragment_saving_n_attachments, count, count),
context.getResources().getQuantityString(R.plurals.ConversationFragment_saving_n_attachments_to_sd_card, count, count));
this.contextReference = new WeakReference<>(context);
this.masterSecretReference = new WeakReference<>(masterSecret);
this.view = new WeakReference<>(view);
this.attachmentCount = count;
}
@Override
protected Integer doInBackground(SaveAttachmentTask.Attachment... attachments) {
protected Pair<Integer, File> doInBackground(SaveAttachmentTask.Attachment... attachments) {
if (attachments == null || attachments.length == 0) {
throw new AssertionError("must pass in at least one attachment");
}
try {
Context context = contextReference.get();
Context context = contextReference.get();
MasterSecret masterSecret = masterSecretReference.get();
File directory = null;
if (!Environment.getExternalStorageDirectory().canWrite()) {
return WRITE_ACCESS_FAILURE;
return new Pair<>(WRITE_ACCESS_FAILURE, null);
}
if (context == null) {
return FAILURE;
return new Pair<>(FAILURE, null);
}
for (Attachment attachment : attachments) {
if (attachment != null && !saveAttachment(context, masterSecret, attachment)) {
return FAILURE;
if (attachment != null) {
directory = saveAttachment(context, masterSecret, attachment);
if (directory == null) return new Pair<>(FAILURE, null);
}
}
return SUCCESS;
if (attachments.length > 1) return new Pair<>(SUCCESS, null);
else return new Pair<>(SUCCESS, directory);
} catch (IOException ioe) {
Log.w(TAG, ioe);
return FAILURE;
return new Pair<>(FAILURE, null);
}
}
private boolean saveAttachment(Context context, MasterSecret masterSecret, Attachment attachment) throws IOException {
String contentType = MediaUtil.getCorrectedMimeType(attachment.contentType);
File mediaFile = constructOutputFile(contentType, attachment.date);
private @Nullable File saveAttachment(Context context, MasterSecret masterSecret, Attachment attachment)
throws IOException
{
String contentType = MediaUtil.getCorrectedMimeType(attachment.contentType);
File mediaFile = constructOutputFile(attachment.fileName, contentType, attachment.date);
InputStream inputStream = PartAuthority.getAttachmentStream(context, masterSecret, attachment.uri);
if (inputStream == null) {
return false;
return null;
}
OutputStream outputStream = new FileOutputStream(mediaFile);
@@ -95,16 +107,16 @@ public class SaveAttachmentTask extends ProgressDialogAsyncTask<SaveAttachmentTa
MediaScannerConnection.scanFile(context, new String[]{mediaFile.getAbsolutePath()},
new String[]{contentType}, null);
return true;
return mediaFile.getParentFile();
}
@Override
protected void onPostExecute(Integer result) {
protected void onPostExecute(final Pair<Integer, File> result) {
super.onPostExecute(result);
Context context = contextReference.get();
final Context context = contextReference.get();
if (context == null) return;
switch (result) {
switch (result.first()) {
case FAILURE:
Toast.makeText(context,
context.getResources().getQuantityText(R.plurals.ConversationFragment_error_while_saving_attachments_to_sd_card,
@@ -112,10 +124,26 @@ public class SaveAttachmentTask extends ProgressDialogAsyncTask<SaveAttachmentTa
Toast.LENGTH_LONG).show();
break;
case SUCCESS:
Toast.makeText(context,
context.getResources().getQuantityText(R.plurals.ConversationFragment_files_saved_successfully,
attachmentCount),
Toast.LENGTH_LONG).show();
Snackbar snackbar = Snackbar.make(view.get(),
context.getResources().getQuantityText(R.plurals.ConversationFragment_files_saved_successfully, attachmentCount),
Snackbar.LENGTH_SHORT);
if (result.second() != null) {
snackbar.setDuration(Snackbar.LENGTH_LONG);
snackbar.setAction(R.string.SaveAttachmentTask_open_directory, new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(Uri.fromFile(result.second()), "resource/folder");
if (intent.resolveActivityInfo(context.getPackageManager(), 0) != null)
{
context.startActivity(intent);
}
}
});
}
snackbar.show();
break;
case WRITE_ACCESS_FAILURE:
Toast.makeText(context, R.string.ConversationFragment_unable_to_write_to_sd_card_exclamation,
@@ -124,7 +152,9 @@ public class SaveAttachmentTask extends ProgressDialogAsyncTask<SaveAttachmentTa
}
}
private File constructOutputFile(String contentType, long timestamp) throws IOException {
private File constructOutputFile(@Nullable String fileName, String contentType, long timestamp)
throws IOException
{
File sdCard = Environment.getExternalStorageDirectory();
File outputDirectory;
@@ -140,32 +170,54 @@ public class SaveAttachmentTask extends ProgressDialogAsyncTask<SaveAttachmentTa
if (!outputDirectory.mkdirs()) Log.w(TAG, "mkdirs() returned false, attempting to continue");
MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton();
String extension = mimeTypeMap.getExtensionFromMimeType(contentType);
SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyy-MM-dd-HHmmss");
String base = "signal-" + dateFormatter.format(timestamp);
if (fileName == null) {
MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton();
String extension = mimeTypeMap.getExtensionFromMimeType(contentType);
SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyy-MM-dd-HHmmss");
String base = "signal-" + dateFormatter.format(timestamp);
if (extension == null) extension = "attach";
if (extension == null) extension = "attach";
fileName = base + "." + extension;
}
int i = 0;
File file = new File(outputDirectory, fileName);
int i = 0;
File file = new File(outputDirectory, base + "." + extension);
while (file.exists()) {
file = new File(outputDirectory, base + "-" + (++i) + "." + extension);
String[] fileParts = getFileNameParts(fileName);
file = new File(outputDirectory, fileParts[0] + "-" + (++i) + "." + fileParts[1]);
}
return file;
}
private String[] getFileNameParts(String fileName) {
String[] result = new String[2];
String[] tokens = fileName.split("\\.(?=[^\\.]+$)");
result[0] = tokens[0];
if (tokens.length > 1) result[1] = tokens[1];
else result[1] = "";
return result;
}
public static class Attachment {
public Uri uri;
public String fileName;
public String contentType;
public long date;
public Attachment(@NonNull Uri uri, @NonNull String contentType, long date) {
public Attachment(@NonNull Uri uri, @NonNull String contentType,
long date, @Nullable String fileName)
{
if (uri == null || contentType == null || date < 0) {
throw new AssertionError("uri, content type, and date must all be specified");
}
this.uri = uri;
this.fileName = fileName;
this.contentType = contentType;
this.date = date;
}

View File

@@ -54,6 +54,7 @@ import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.text.DecimalFormat;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
@@ -164,6 +165,14 @@ public class Util {
}
}
public static void close(InputStream in) {
try {
in.close();
} catch (IOException e) {
Log.w(TAG, e);
}
}
public static void close(OutputStream out) {
try {
out.close();
@@ -172,6 +181,19 @@ public class Util {
}
}
public static long getStreamLength(InputStream in) throws IOException {
byte[] buffer = new byte[4096];
int totalSize = 0;
int read;
while ((read = in.read(buffer)) != -1) {
totalSize += read;
}
return totalSize;
}
public static String canonicalizeNumber(Context context, String number)
throws InvalidNumberException
{
@@ -463,4 +485,13 @@ public class Util {
public static boolean isEquals(@Nullable Long first, long second) {
return first != null && first == second;
}
public static String getPrettyFileSize(long sizeBytes) {
if (sizeBytes <= 0) return "0";
String[] units = new String[]{"B", "kB", "MB", "GB", "TB"};
int digitGroups = (int) (Math.log10(sizeBytes) / Math.log10(1024));
return new DecimalFormat("#,##0.#").format(sizeBytes/Math.pow(1024, digitGroups)) + " " + units[digitGroups];
}
}