basic arbitrary file transfer

iNPUTmice created

Change summary

src/main/java/eu/siacs/conversations/entities/Downloadable.java             |   6 
src/main/java/eu/siacs/conversations/entities/DownloadableFile.java         |  12 
src/main/java/eu/siacs/conversations/entities/Message.java                  |  25 
src/main/java/eu/siacs/conversations/http/HttpConnection.java               |  15 
src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java       |   7 
src/main/java/eu/siacs/conversations/persistance/FileBackend.java           |  73 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java    |  39 
src/main/java/eu/siacs/conversations/ui/ConversationActivity.java           |  46 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java         |  97 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnection.java      | 131 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleSocks5Transport.java |   8 
src/main/res/menu/attachment_choices.xml                                    |   3 
src/main/res/values/strings.xml                                             |   6 
13 files changed, 332 insertions(+), 136 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/entities/Downloadable.java πŸ”—

@@ -2,7 +2,7 @@ package eu.siacs.conversations.entities;
 
 public interface Downloadable {
 
-	public final String[] VALID_EXTENSIONS = {"webp", "jpeg", "jpg", "png", "jpe"};
+	public final String[] VALID_IMAGE_EXTENSIONS = {"webp", "jpeg", "jpg", "png", "jpe"};
 	public final String[] VALID_CRYPTO_EXTENSIONS = {"pgp", "gpg", "otr"};
 
 	public static final int STATUS_UNKNOWN = 0x200;
@@ -18,4 +18,8 @@ public interface Downloadable {
 	public int getStatus();
 
 	public long getFileSize();
+
+	public int getProgress();
+
+	public String getMimeType();
 }

src/main/java/eu/siacs/conversations/entities/DownloadableFile.java πŸ”—

@@ -6,6 +6,7 @@ import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.net.URLConnection;
 import java.security.InvalidAlgorithmParameterException;
 import java.security.InvalidKeyException;
 import java.security.Key;
@@ -28,6 +29,7 @@ public class DownloadableFile extends File {
 	private long expectedSize = 0;
 	private String sha1sum;
 	private Key aeskey;
+	private String mime;
 
 	private byte[] iv = { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
 			0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0xf };
@@ -52,6 +54,16 @@ public class DownloadableFile extends File {
 		}
 	}
 
+	public String getMimeType() {
+		if (mime==null) {
+			mime = URLConnection.guessContentTypeFromName(this.getAbsolutePath());
+			if (mime == null) {
+				mime = "";
+			}
+		}
+		return mime;
+	}
+
 	public void setExpectedSize(long size) {
 		this.expectedSize = size;
 	}

src/main/java/eu/siacs/conversations/entities/Message.java πŸ”—

@@ -32,7 +32,7 @@ public class Message extends AbstractEntity {
 
 	public static final int TYPE_TEXT = 0;
 	public static final int TYPE_IMAGE = 1;
-	public static final int TYPE_AUDIO = 2;
+	public static final int TYPE_FILE = 2;
 	public static final int TYPE_STATUS = 3;
 	public static final int TYPE_PRIVATE = 4;
 
@@ -45,6 +45,7 @@ public class Message extends AbstractEntity {
 	public static String STATUS = "status";
 	public static String TYPE = "type";
 	public static String REMOTE_MSG_ID = "remoteMsgId";
+	public static String RELATIVE_FILE_PATH = "relativeFilePath";
 	public boolean markable = false;
 	protected String conversationUuid;
 	protected Jid counterpart;
@@ -55,6 +56,7 @@ public class Message extends AbstractEntity {
 	protected int encryption;
 	protected int status;
 	protected int type;
+	protected String relativeFilePath;
 	protected boolean read = true;
 	protected String remoteMsgId = null;
 	protected Conversation conversation = null;
@@ -74,13 +76,13 @@ public class Message extends AbstractEntity {
 		this(java.util.UUID.randomUUID().toString(), conversation.getUuid(),
 				conversation.getContactJid().toBareJid(), null, body, System
 						.currentTimeMillis(), encryption,
-				status, TYPE_TEXT, null);
+				status, TYPE_TEXT, null,null);
 		this.conversation = conversation;
 	}
 
 	public Message(final String uuid, final String conversationUUid, final Jid counterpart,
 				   final String trueCounterpart, final String body, final long timeSent,
-				   final int encryption, final int status, final int type, final String remoteMsgId) {
+				   final int encryption, final int status, final int type, final String remoteMsgId, final String relativeFilePath) {
 		this.uuid = uuid;
 		this.conversationUuid = conversationUUid;
 		this.counterpart = counterpart;
@@ -91,6 +93,7 @@ public class Message extends AbstractEntity {
 		this.status = status;
 		this.type = type;
 		this.remoteMsgId = remoteMsgId;
+		this.relativeFilePath = relativeFilePath;
 	}
 
 	public static Message fromCursor(Cursor cursor) {
@@ -114,7 +117,8 @@ public class Message extends AbstractEntity {
 				cursor.getInt(cursor.getColumnIndex(ENCRYPTION)),
 				cursor.getInt(cursor.getColumnIndex(STATUS)),
 				cursor.getInt(cursor.getColumnIndex(TYPE)),
-				cursor.getString(cursor.getColumnIndex(REMOTE_MSG_ID)));
+				cursor.getString(cursor.getColumnIndex(REMOTE_MSG_ID)),
+				cursor.getString(cursor.getColumnIndex(RELATIVE_FILE_PATH)));
 	}
 
 	public static Message createStatusMessage(Conversation conversation) {
@@ -141,6 +145,7 @@ public class Message extends AbstractEntity {
 		values.put(STATUS, status);
 		values.put(TYPE, type);
 		values.put(REMOTE_MSG_ID, remoteMsgId);
+		values.put(RELATIVE_FILE_PATH, relativeFilePath);
 		return values;
 	}
 
@@ -205,6 +210,14 @@ public class Message extends AbstractEntity {
 		this.status = status;
 	}
 
+	public void setRelativeFilePath(String path) {
+		this.relativeFilePath = path;
+	}
+
+	public String getRelativeFilePath() {
+		return  this.relativeFilePath;
+	}
+
 	public String getRemoteMsgId() {
 		return this.remoteMsgId;
 	}
@@ -376,14 +389,14 @@ public class Message extends AbstractEntity {
 			}
 			String[] extensionParts = filename.split("\\.");
 			if (extensionParts.length == 2
-					&& Arrays.asList(Downloadable.VALID_EXTENSIONS).contains(
+					&& Arrays.asList(Downloadable.VALID_IMAGE_EXTENSIONS).contains(
 					extensionParts[extensionParts.length - 1])) {
 				return true;
 			} else if (extensionParts.length == 3
 					&& Arrays
 					.asList(Downloadable.VALID_CRYPTO_EXTENSIONS)
 					.contains(extensionParts[extensionParts.length - 1])
-					&& Arrays.asList(Downloadable.VALID_EXTENSIONS).contains(
+					&& Arrays.asList(Downloadable.VALID_IMAGE_EXTENSIONS).contains(
 					extensionParts[extensionParts.length - 2])) {
 				return true;
 			} else {

src/main/java/eu/siacs/conversations/http/HttpConnection.java πŸ”—

@@ -37,6 +37,7 @@ public class HttpConnection implements Downloadable {
 	private DownloadableFile file;
 	private int mStatus = Downloadable.STATUS_UNKNOWN;
 	private boolean acceptedAutomatically = false;
+	private int mProgress = 0;
 
 	public HttpConnection(HttpConnectionManager manager) {
 		this.mHttpConnectionManager = manager;
@@ -235,10 +236,14 @@ public class HttpConnection implements Downloadable {
 			if (os == null) {
 				throw new IOException();
 			}
+			long transmitted = 0;
+			long expected = file.getExpectedSize();
 			int count = -1;
 			byte[] buffer = new byte[1024];
 			while ((count = is.read(buffer)) != -1) {
+				transmitted += count;
 				os.write(buffer, 0, count);
+				mProgress = (int) (expected * 100 / transmitted);
 			}
 			os.flush();
 			os.close();
@@ -272,4 +277,14 @@ public class HttpConnection implements Downloadable {
 			return 0;
 		}
 	}
+
+	@Override
+	public int getProgress() {
+		return this.mProgress;
+	}
+
+	@Override
+	public String getMimeType() {
+		return "";
+	}
 }

src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java πŸ”—

@@ -22,7 +22,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
 	private static DatabaseBackend instance = null;
 
 	private static final String DATABASE_NAME = "history";
-	private static final int DATABASE_VERSION = 9;
+	private static final int DATABASE_VERSION = 10;
 
 	private static String CREATE_CONTATCS_STATEMENT = "create table "
 			+ Contact.TABLENAME + "(" + Contact.ACCOUNT + " TEXT, "
@@ -64,6 +64,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
 				+ " TEXT, " + Message.TRUE_COUNTERPART + " TEXT,"
 				+ Message.BODY + " TEXT, " + Message.ENCRYPTION + " NUMBER, "
 				+ Message.STATUS + " NUMBER," + Message.TYPE + " NUMBER, "
+				+ Message.RELATIVE_FILE_PATH + " TEXT, "
 				+ Message.REMOTE_MSG_ID + " TEXT, FOREIGN KEY("
 				+ Message.CONVERSATION + ") REFERENCES "
 				+ Conversation.TABLENAME + "(" + Conversation.UUID
@@ -110,6 +111,10 @@ public class DatabaseBackend extends SQLiteOpenHelper {
             db.execSQL("ALTER TABLE " + Contact.TABLENAME + " ADD COLUMN "
                     + Contact.LAST_PRESENCE + " TEXT");
         }
+		if (oldVersion < 10 && newVersion >= 10) {
+			db.execSQL("ALTER TABLE " + Message.TABLENAME + " ADD COLUMN "
+					+ Message.RELATIVE_FILE_PATH + " TEXT");
+		}
 	}
 
 	public static synchronized DatabaseBackend getInstance(Context context) {

src/main/java/eu/siacs/conversations/persistance/FileBackend.java πŸ”—

@@ -2,11 +2,13 @@ package eu.siacs.conversations.persistance;
 
 import java.io.ByteArrayOutputStream;
 import java.io.File;
+import java.io.FileInputStream;
 import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.net.URLConnection;
 import java.security.DigestOutputStream;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
@@ -14,6 +16,7 @@ import java.text.SimpleDateFormat;
 import java.util.Date;
 import java.util.Locale;
 
+import android.content.ContentResolver;
 import android.database.Cursor;
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
@@ -53,25 +56,34 @@ public class FileBackend {
 	}
 
 	public DownloadableFile getFile(Message message, boolean decrypted) {
-		StringBuilder filename = new StringBuilder();
-		filename.append(getConversationsDirectory());
-		filename.append(message.getUuid());
-		if ((decrypted) || (message.getEncryption() == Message.ENCRYPTION_NONE)) {
-			filename.append(".webp");
+		String path = message.getRelativeFilePath();
+		if (path != null && !path.isEmpty()) {
+			if (path.startsWith("/")) {
+				return new DownloadableFile(path);
+			} else {
+				return new DownloadableFile(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)+"/"+path);
+			}
 		} else {
-			if (message.getEncryption() == Message.ENCRYPTION_OTR) {
+			StringBuilder filename = new StringBuilder();
+			filename.append(getConversationsDirectory());
+			filename.append(message.getUuid());
+			if ((decrypted) || (message.getEncryption() == Message.ENCRYPTION_NONE)) {
 				filename.append(".webp");
 			} else {
-				filename.append(".webp.pgp");
+				if (message.getEncryption() == Message.ENCRYPTION_OTR) {
+					filename.append(".webp");
+				} else {
+					filename.append(".webp.pgp");
+				}
 			}
+			return new DownloadableFile(filename.toString());
 		}
-		return new DownloadableFile(filename.toString());
 	}
 
 	public static String getConversationsDirectory() {
 		return Environment.getExternalStoragePublicDirectory(
-				Environment.DIRECTORY_PICTURES).getAbsolutePath()
-				+ "/Conversations/";
+			Environment.DIRECTORY_PICTURES).getAbsolutePath()
+			+ "/Conversations/";
 	}
 
 	public Bitmap resize(Bitmap originalBitmap, int size) {
@@ -103,13 +115,34 @@ public class FileBackend {
 		return Bitmap.createBitmap(bitmap, 0, 0, w, h, mtx, true);
 	}
 
+	public String getOriginalPath(Uri uri) {
+		String path = null;
+		if (uri.getScheme().equals("file")) {
+			path = uri.getPath();
+		} else {
+			String[] projection = {MediaStore.MediaColumns.DATA};
+			Cursor metaCursor = mXmppConnectionService.getContentResolver().query(uri,
+					projection, null, null, null);
+			if (metaCursor != null) {
+				try {
+					if (metaCursor.moveToFirst()) {
+						path = metaCursor.getString(0);
+					}
+				} finally {
+					metaCursor.close();
+				}
+			}
+		}
+		return path;
+	}
+
 	public DownloadableFile copyImageToPrivateStorage(Message message, Uri image)
-			throws ImageCopyException {
+			throws FileCopyException {
 		return this.copyImageToPrivateStorage(message, image, 0);
 	}
 
 	private DownloadableFile copyImageToPrivateStorage(Message message,
-			Uri image, int sampleSize) throws ImageCopyException {
+			Uri image, int sampleSize) throws FileCopyException {
 		try {
 			InputStream is = mXmppConnectionService.getContentResolver()
 					.openInputStream(image);
@@ -125,7 +158,7 @@ public class FileBackend {
 			originalBitmap = BitmapFactory.decodeStream(is, null, options);
 			is.close();
 			if (originalBitmap == null) {
-				throw new ImageCopyException(R.string.error_not_an_image_file);
+				throw new FileCopyException(R.string.error_not_an_image_file);
 			}
 			Bitmap scalledBitmap = resize(originalBitmap, IMAGE_SIZE);
 			originalBitmap = null;
@@ -137,7 +170,7 @@ public class FileBackend {
 			boolean success = scalledBitmap.compress(
 					Bitmap.CompressFormat.WEBP, 75, os);
 			if (!success) {
-				throw new ImageCopyException(R.string.error_compressing_image);
+				throw new FileCopyException(R.string.error_compressing_image);
 			}
 			os.flush();
 			os.close();
@@ -147,18 +180,18 @@ public class FileBackend {
 			message.setBody(Long.toString(size) + ',' + width + ',' + height);
 			return file;
 		} catch (FileNotFoundException e) {
-			throw new ImageCopyException(R.string.error_file_not_found);
+			throw new FileCopyException(R.string.error_file_not_found);
 		} catch (IOException e) {
-			throw new ImageCopyException(R.string.error_io_exception);
+			throw new FileCopyException(R.string.error_io_exception);
 		} catch (SecurityException e) {
-			throw new ImageCopyException(
+			throw new FileCopyException(
 					R.string.error_security_exception_during_image_copy);
 		} catch (OutOfMemoryError e) {
 			++sampleSize;
 			if (sampleSize <= 3) {
 				return copyImageToPrivateStorage(message, image, sampleSize);
 			} else {
-				throw new ImageCopyException(R.string.error_out_of_memory);
+				throw new FileCopyException(R.string.error_out_of_memory);
 			}
 		}
 	}
@@ -400,11 +433,11 @@ public class FileBackend {
 		return Uri.parse("file://" + file.getAbsolutePath());
 	}
 
-	public class ImageCopyException extends Exception {
+	public class FileCopyException extends Exception {
 		private static final long serialVersionUID = -1010013599132881427L;
 		private int resId;
 
-		public ImageCopyException(int resId) {
+		public FileCopyException(int resId) {
 			this.resId = resId;
 		}
 

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java πŸ”—

@@ -56,6 +56,7 @@ import eu.siacs.conversations.entities.Bookmark;
 import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.Downloadable;
+import eu.siacs.conversations.entities.DownloadableFile;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.MucOptions;
 import eu.siacs.conversations.entities.MucOptions.OnRenameListener;
@@ -294,6 +295,27 @@ public class XmppConnectionService extends Service {
 		return this.mAvatarService;
 	}
 
+	public Message attachFileToConversation(Conversation conversation, final Uri uri) {
+		String path = getFileBackend().getOriginalPath(uri);
+		if (path!=null) {
+			Log.d(Config.LOGTAG,"file path : "+path);
+			Message message;
+			if (conversation.getNextEncryption(forceEncryption()) == Message.ENCRYPTION_PGP) {
+				message = new Message(conversation, "",
+						Message.ENCRYPTION_DECRYPTED);
+			} else {
+				message = new Message(conversation, "",
+						conversation.getNextEncryption(forceEncryption()));
+			}
+			message.setCounterpart(conversation.getNextCounterpart());
+			message.setType(Message.TYPE_FILE);
+			message.setStatus(Message.STATUS_OFFERED);
+			message.setRelativeFilePath(path);
+			return message;
+		}
+		return null;
+	}
+
 	public Message attachImageToConversation(final Conversation conversation,
 											 final Uri uri, final UiCallback<Message> callback) {
 		final Message message;
@@ -312,13 +334,14 @@ public class XmppConnectionService extends Service {
 			@Override
 			public void run() {
 				try {
-					getFileBackend().copyImageToPrivateStorage(message, uri);
+					DownloadableFile file = getFileBackend().copyImageToPrivateStorage(message, uri);
+					message.setRelativeFilePath(file.getName());
 					if (conversation.getNextEncryption(forceEncryption()) == Message.ENCRYPTION_PGP) {
 						getPgpEngine().encrypt(message, callback);
 					} else {
 						callback.success(message);
 					}
-				} catch (FileBackend.ImageCopyException e) {
+				} catch (FileBackend.FileCopyException e) {
 					callback.error(e.getResId(), message);
 				}
 			}
@@ -552,7 +575,7 @@ public class XmppConnectionService extends Service {
 		boolean send = false;
 		if (account.getStatus() == Account.STATUS_ONLINE
 				&& account.getXmppConnection() != null) {
-			if (message.getType() == Message.TYPE_IMAGE) {
+			if (message.getType() == Message.TYPE_IMAGE || message.getType() == Message.TYPE_FILE) {
 				if (message.getCounterpart() != null) {
 					if (message.getEncryption() == Message.ENCRYPTION_OTR) {
 						if (!conv.hasValidOtrSession()) {
@@ -1988,5 +2011,15 @@ public class XmppConnectionService extends Service {
 			return 0;
 		}
 
+		@Override
+		public int getProgress() {
+			return 0;
+		}
+
+		@Override
+		public String getMimeType() {
+			return "";
+		}
+
 	}
 }

src/main/java/eu/siacs/conversations/ui/ConversationActivity.java πŸ”—

@@ -15,6 +15,7 @@ import android.os.SystemClock;
 import android.provider.MediaStore;
 import android.support.v4.widget.SlidingPaneLayout;
 import android.support.v4.widget.SlidingPaneLayout.PanelSlideListener;
+import android.util.Log;
 import android.view.KeyEvent;
 import android.view.Menu;
 import android.view.MenuItem;
@@ -31,6 +32,7 @@ import android.widget.Toast;
 import java.util.ArrayList;
 import java.util.List;
 
+import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.Conversation;
@@ -52,13 +54,14 @@ public class ConversationActivity extends XmppActivity implements
 	public static final int REQUEST_SEND_MESSAGE = 0x0201;
 	public static final int REQUEST_DECRYPT_PGP = 0x0202;
 	public static final int REQUEST_ENCRYPT_MESSAGE = 0x0207;
-	private static final int REQUEST_ATTACH_FILE_DIALOG = 0x0203;
+	private static final int REQUEST_ATTACH_IMAGE_DIALOG = 0x0203;
 	private static final int REQUEST_IMAGE_CAPTURE = 0x0204;
 	private static final int REQUEST_RECORD_AUDIO = 0x0205;
 	private static final int REQUEST_SEND_PGP_IMAGE = 0x0206;
+	private static final int REQUEST_ATTACH_FILE_DIALOG = 0x0208;
 	private static final int ATTACHMENT_CHOICE_CHOOSE_IMAGE = 0x0301;
 	private static final int ATTACHMENT_CHOICE_TAKE_PHOTO = 0x0302;
-	private static final int ATTACHMENT_CHOICE_RECORD_VOICE = 0x0303;
+	private static final int ATTACHMENT_CHOICE_CHOOSE_FILE = 0x0303;
 	private static final String STATE_OPEN_CONVERSATION = "state_open_conversation";
 	private static final String STATE_PANEL_OPEN = "state_panel_open";
 	private static final String STATE_PENDING_URI = "state_pending_uri";
@@ -66,6 +69,7 @@ public class ConversationActivity extends XmppActivity implements
 	private String mOpenConverstaion = null;
 	private boolean mPanelOpen = true;
 	private Uri mPendingImageUri = null;
+	private Uri mPendingFileUri = null;
 
 	private View mContentView;
 
@@ -306,13 +310,16 @@ public class ConversationActivity extends XmppActivity implements
 					Intent attachFileIntent = new Intent();
 					attachFileIntent.setType("image/*");
 					attachFileIntent.setAction(Intent.ACTION_GET_CONTENT);
+					Intent chooser = Intent.createChooser(attachFileIntent,
+							getString(R.string.attach_file));
+					startActivityForResult(chooser, REQUEST_ATTACH_IMAGE_DIALOG);
+				} else if (attachmentChoice == ATTACHMENT_CHOICE_CHOOSE_FILE) {
+					Intent attachFileIntent = new Intent();
+					attachFileIntent.setType("file/*");
+					attachFileIntent.setAction(Intent.ACTION_GET_CONTENT);
 					Intent chooser = Intent.createChooser(attachFileIntent,
 							getString(R.string.attach_file));
 					startActivityForResult(chooser, REQUEST_ATTACH_FILE_DIALOG);
-				} else if (attachmentChoice == ATTACHMENT_CHOICE_RECORD_VOICE) {
-					Intent intent = new Intent(
-							MediaStore.Audio.Media.RECORD_SOUND_ACTION);
-					startActivityForResult(intent, REQUEST_RECORD_AUDIO);
 				}
 			}
 		});
@@ -483,7 +490,7 @@ public class ConversationActivity extends XmppActivity implements
 								attachFile(ATTACHMENT_CHOICE_TAKE_PHOTO);
 								break;
 							case R.id.attach_record_voice:
-								attachFile(ATTACHMENT_CHOICE_RECORD_VOICE);
+								attachFile(ATTACHMENT_CHOICE_CHOOSE_FILE);
 								break;
 						}
 						return false;
@@ -675,14 +682,17 @@ public class ConversationActivity extends XmppActivity implements
 		} else {
 			showConversationsOverview();
 			mPendingImageUri = null;
+			mPendingFileUri = null;
 			setSelectedConversation(conversationList.get(0));
 			this.mConversationFragment.reInit(getSelectedConversation());
 		}
 
 		if (mPendingImageUri != null) {
-			attachImageToConversation(getSelectedConversation(),
-					mPendingImageUri);
+			attachImageToConversation(getSelectedConversation(),mPendingImageUri);
 			mPendingImageUri = null;
+		} else if (mPendingFileUri != null) {
+			attachFileToConversation(getSelectedConversation(),mPendingFileUri);
+			mPendingFileUri = null;
 		}
 		ExceptionHelper.checkForCrash(this, this.xmppConnectionService);
 		setIntent(new Intent());
@@ -726,13 +736,20 @@ public class ConversationActivity extends XmppActivity implements
 					selectedFragment.hideSnackbar();
 					selectedFragment.updateMessages();
 				}
-			} else if (requestCode == REQUEST_ATTACH_FILE_DIALOG) {
+			} else if (requestCode == REQUEST_ATTACH_IMAGE_DIALOG) {
 				mPendingImageUri = data.getData();
 				if (xmppConnectionServiceBound) {
 					attachImageToConversation(getSelectedConversation(),
 							mPendingImageUri);
 					mPendingImageUri = null;
 				}
+			} else if (requestCode == REQUEST_ATTACH_FILE_DIALOG) {
+				mPendingFileUri = data.getData();
+				if (xmppConnectionServiceBound) {
+					attachFileToConversation(getSelectedConversation(),
+							mPendingFileUri);
+					mPendingFileUri = null;
+				}
 			} else if (requestCode == REQUEST_SEND_PGP_IMAGE) {
 
 			} else if (requestCode == ATTACHMENT_CHOICE_CHOOSE_IMAGE) {
@@ -754,9 +771,6 @@ public class ConversationActivity extends XmppActivity implements
 						Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
 				intent.setData(mPendingImageUri);
 				sendBroadcast(intent);
-			} else if (requestCode == REQUEST_RECORD_AUDIO) {
-				attachAudioToConversation(getSelectedConversation(),
-						data.getData());
 			}
 		} else {
 			if (requestCode == REQUEST_IMAGE_CAPTURE) {
@@ -765,8 +779,10 @@ public class ConversationActivity extends XmppActivity implements
 		}
 	}
 
-	private void attachAudioToConversation(Conversation conversation, Uri uri) {
-
+	private void attachFileToConversation(Conversation conversation, Uri uri) {
+		Log.d(Config.LOGTAG, "attachFileToConversation");
+		Message message = xmppConnectionService.attachFileToConversation(conversation,uri);
+		xmppConnectionService.sendMessage(message);
 	}
 
 	private void attachImageToConversation(Conversation conversation, Uri uri) {

src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java πŸ”—

@@ -2,15 +2,18 @@ package eu.siacs.conversations.ui.adapter;
 
 import android.content.Intent;
 import android.graphics.Typeface;
+import android.net.Uri;
 import android.text.Spannable;
 import android.text.SpannableString;
 import android.text.style.ForegroundColorSpan;
 import android.text.style.StyleSpan;
 import android.util.DisplayMetrics;
+import android.util.Log;
 import android.view.View;
 import android.view.View.OnClickListener;
 import android.view.View.OnLongClickListener;
 import android.view.ViewGroup;
+import android.webkit.MimeTypeMap;
 import android.widget.ArrayAdapter;
 import android.widget.Button;
 import android.widget.ImageView;
@@ -18,6 +21,9 @@ import android.widget.LinearLayout;
 import android.widget.TextView;
 import android.widget.Toast;
 
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.InputStream;
 import java.util.List;
 
 import eu.siacs.conversations.Config;
@@ -25,6 +31,7 @@ import eu.siacs.conversations.R;
 import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.Downloadable;
+import eu.siacs.conversations.entities.DownloadableFile;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.Message.ImageParams;
 import eu.siacs.conversations.ui.ConversationActivity;
@@ -181,13 +188,13 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 		}
 	}
 
-	private void displayInfoMessage(ViewHolder viewHolder, int r) {
+	private void displayInfoMessage(ViewHolder viewHolder, String text) {
 		if (viewHolder.download_button != null) {
 			viewHolder.download_button.setVisibility(View.GONE);
 		}
 		viewHolder.image.setVisibility(View.GONE);
 		viewHolder.messageBody.setVisibility(View.VISIBLE);
-		viewHolder.messageBody.setText(getContext().getString(r));
+		viewHolder.messageBody.setText(text);
 		viewHolder.messageBody.setTextColor(activity.getSecondaryTextColor());
 		viewHolder.messageBody.setTypeface(null, Typeface.ITALIC);
 		viewHolder.messageBody.setTextIsSelectable(false);
@@ -252,11 +259,11 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 	}
 
 	private void displayDownloadableMessage(ViewHolder viewHolder,
-			final Message message, int resid) {
+			final Message message, String text) {
 		viewHolder.image.setVisibility(View.GONE);
 		viewHolder.messageBody.setVisibility(View.GONE);
 		viewHolder.download_button.setVisibility(View.VISIBLE);
-		viewHolder.download_button.setText(resid);
+		viewHolder.download_button.setText(text);
 		viewHolder.download_button.setOnClickListener(new OnClickListener() {
 
 			@Override
@@ -267,6 +274,21 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 		viewHolder.download_button.setOnLongClickListener(openContextMenu);
 	}
 
+	private void displayOpenableMessage(ViewHolder viewHolder,final Message message) {
+		viewHolder.image.setVisibility(View.GONE);
+		viewHolder.messageBody.setVisibility(View.GONE);
+		viewHolder.download_button.setVisibility(View.VISIBLE);
+		viewHolder.download_button.setText(activity.getString(R.string.open_file,activity.xmppConnectionService.getFileBackend().getFile(message).getMimeType()));
+		viewHolder.download_button.setOnClickListener(new OnClickListener() {
+
+			@Override
+			public void onClick(View v) {
+				openDonwloadable(message);
+			}
+		});
+		viewHolder.download_button.setOnLongClickListener(openContextMenu);
+	}
+
 	private void displayImageMessage(ViewHolder viewHolder,
 			final Message message) {
 		if (viewHolder.download_button != null) {
@@ -455,42 +477,46 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 					});
 		}
 
-		if (item.getType() == Message.TYPE_IMAGE
-				|| item.getDownloadable() != null) {
+		if (item.getDownloadable() != null) {
 			Downloadable d = item.getDownloadable();
-			if (d != null && d.getStatus() == Downloadable.STATUS_DOWNLOADING) {
-				displayInfoMessage(viewHolder, R.string.receiving_image);
-			} else if (d != null
-					&& d.getStatus() == Downloadable.STATUS_CHECKING) {
-				displayInfoMessage(viewHolder, R.string.checking_image);
-			} else if (d != null
-					&& d.getStatus() == Downloadable.STATUS_DELETED) {
-				displayInfoMessage(viewHolder, R.string.image_file_deleted);
-			} else if (d != null && d.getStatus() == Downloadable.STATUS_OFFER) {
-				displayDownloadableMessage(viewHolder, item,
-						R.string.download_image);
-			} else if (d != null
-					&& d.getStatus() == Downloadable.STATUS_OFFER_CHECK_FILESIZE) {
-				displayDownloadableMessage(viewHolder, item,
-						R.string.check_image_filesize);
-			} else if (d != null && d.getStatus() == Downloadable.STATUS_FAILED) {
-				displayInfoMessage(viewHolder, R.string.image_transmission_failed);
-			} else if ((item.getEncryption() == Message.ENCRYPTION_DECRYPTED)
-					|| (item.getEncryption() == Message.ENCRYPTION_NONE)
-					|| (item.getEncryption() == Message.ENCRYPTION_OTR)) {
-				displayImageMessage(viewHolder, item);
-			} else if (item.getEncryption() == Message.ENCRYPTION_PGP) {
-				displayInfoMessage(viewHolder, R.string.encrypted_message);
-			} else {
+			if (d.getStatus() == Downloadable.STATUS_DOWNLOADING) {
+				if (item.getType() == Message.TYPE_FILE) {
+					displayInfoMessage(viewHolder,activity.getString(R.string.receiving_file,d.getMimeType(),d.getProgress()));
+				} else {
+					displayInfoMessage(viewHolder,activity.getString(R.string.receiving_image,d.getProgress()));
+				}
+			} else if (d.getStatus() == Downloadable.STATUS_CHECKING) {
+				displayInfoMessage(viewHolder,activity.getString(R.string.checking_image));
+			} else if (d.getStatus() == Downloadable.STATUS_DELETED) {
+				displayInfoMessage(viewHolder,activity.getString(R.string.image_file_deleted));
+			} else if (d.getStatus() == Downloadable.STATUS_OFFER) {
+				if (item.getType() == Message.TYPE_FILE) {
+					displayDownloadableMessage(viewHolder,item,activity.getString(R.string.download_file,d.getMimeType()));
+				} else {
+					displayDownloadableMessage(viewHolder, item,activity.getString(R.string.download_image));
+				}
+			} else if (d.getStatus() == Downloadable.STATUS_OFFER_CHECK_FILESIZE) {
+				displayDownloadableMessage(viewHolder, item,activity.getString(R.string.check_image_filesize));
+			} else if (d.getStatus() == Downloadable.STATUS_FAILED) {
+				displayInfoMessage(viewHolder, activity.getString(R.string.image_transmission_failed));
+			}
+		} else if (item.getType() == Message.TYPE_IMAGE) {
+			if (item.getEncryption() == Message.ENCRYPTION_PGP) {
+				displayInfoMessage(viewHolder,activity.getString(R.string.encrypted_message));
+			} else if (item.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED) {
 				displayDecryptionFailed(viewHolder);
+			} else {
+				displayImageMessage(viewHolder, item);
 			}
+		} else if (item.getType() == Message.TYPE_FILE) {
+			displayOpenableMessage(viewHolder,item);
 		} else {
 			if (item.getEncryption() == Message.ENCRYPTION_PGP) {
 				if (activity.hasPgp()) {
-					displayInfoMessage(viewHolder, R.string.encrypted_message);
+					displayInfoMessage(viewHolder,activity.getString(R.string.encrypted_message));
 				} else {
 					displayInfoMessage(viewHolder,
-							R.string.install_openkeychain);
+							activity.getString(R.string.install_openkeychain));
                     if (viewHolder != null) {
                         viewHolder.message_box
                                 .setOnClickListener(new OnClickListener() {
@@ -524,6 +550,13 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 		}
 	}
 
+	public void openDonwloadable(Message message) {
+		DownloadableFile file = activity.xmppConnectionService.getFileBackend().getFile(message);
+		Intent intent = new Intent(Intent.ACTION_VIEW);
+		intent.setDataAndType(Uri.fromFile(file), file.getMimeType());
+		getContext().startActivity(intent);
+	}
+
 	public interface OnContactPictureClicked {
 		public void onContactPictureClicked(Message message);
 	}

src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnection.java πŸ”—

@@ -11,6 +11,7 @@ import java.util.concurrent.ConcurrentHashMap;
 import android.content.Intent;
 import android.graphics.BitmapFactory;
 import android.net.Uri;
+import android.os.SystemClock;
 import android.util.Log;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.entities.Account;
@@ -29,9 +30,6 @@ import eu.siacs.conversations.xmpp.stanzas.IqPacket;
 
 public class JingleConnection implements Downloadable {
 
-	private final String[] extensions = { "webp", "jpeg", "jpg", "png" };
-	private final String[] cryptoExtensions = { "pgp", "gpg", "otr" };
-
 	private JingleConnectionManager mJingleConnectionManager;
 	private XmppConnectionService mXmppConnectionService;
 
@@ -62,6 +60,9 @@ public class JingleConnection implements Downloadable {
 	private String contentName;
 	private String contentCreator;
 
+	private int mProgress = 0;
+	private long mLastGuiRefresh = 0;
+
 	private boolean receivedCandidate = false;
 	private boolean sentCandidate = false;
 
@@ -258,7 +259,6 @@ public class JingleConnection implements Downloadable {
 						packet.getFrom().toBareJid(), false);
 		this.message = new Message(conversation, "", Message.ENCRYPTION_NONE);
 		this.message.setStatus(Message.STATUS_RECEIVED);
-		this.message.setType(Message.TYPE_IMAGE);
 		this.mStatus = Downloadable.STATUS_OFFER;
 		this.message.setDownloadable(this);
         final Jid from = packet.getFrom();
@@ -278,68 +278,71 @@ public class JingleConnection implements Downloadable {
 			Element fileSize = fileOffer.findChild("size");
 			Element fileNameElement = fileOffer.findChild("name");
 			if (fileNameElement != null) {
-				boolean supportedFile = false;
 				String[] filename = fileNameElement.getContent()
 						.toLowerCase(Locale.US).split("\\.");
-				if (Arrays.asList(this.extensions).contains(
+				if (Arrays.asList(VALID_IMAGE_EXTENSIONS).contains(
 						filename[filename.length - 1])) {
-					supportedFile = true;
-				} else if (Arrays.asList(this.cryptoExtensions).contains(
+					message.setType(Message.TYPE_IMAGE);
+				} else if (Arrays.asList(VALID_CRYPTO_EXTENSIONS).contains(
 						filename[filename.length - 1])) {
 					if (filename.length == 3) {
-						if (Arrays.asList(this.extensions).contains(
+						if (Arrays.asList(VALID_IMAGE_EXTENSIONS).contains(
 								filename[filename.length - 2])) {
-							supportedFile = true;
-							if (filename[filename.length - 1].equals("otr")) {
-								Log.d(Config.LOGTAG, "receiving otr file");
-								this.message
-										.setEncryption(Message.ENCRYPTION_OTR);
-							} else {
-								this.message
-										.setEncryption(Message.ENCRYPTION_PGP);
-							}
+							message.setType(Message.TYPE_IMAGE);
+						} else {
+							message.setType(Message.TYPE_FILE);
 						}
-					}
-				}
-				if (supportedFile) {
-					long size = Long.parseLong(fileSize.getContent());
-					message.setBody(Long.toString(size));
-					conversation.add(message);
-					mXmppConnectionService.updateConversationUi();
-					if (size <= this.mJingleConnectionManager
-							.getAutoAcceptFileSize()) {
-						Log.d(Config.LOGTAG, "auto accepting file from "
-								+ packet.getFrom());
-						this.acceptedAutomatically = true;
-						this.sendAccept();
-					} else {
-						message.markUnread();
-						Log.d(Config.LOGTAG,
-								"not auto accepting new file offer with size: "
-										+ size
-										+ " allowed size:"
-										+ this.mJingleConnectionManager
-												.getAutoAcceptFileSize());
-						this.mXmppConnectionService.getNotificationService()
-								.push(message);
-					}
-					this.file = this.mXmppConnectionService.getFileBackend()
-							.getFile(message, false);
-					if (message.getEncryption() == Message.ENCRYPTION_OTR) {
-						byte[] key = conversation.getSymmetricKey();
-						if (key == null) {
-							this.sendCancel();
-							this.cancel();
-							return;
+						if (filename[filename.length - 1].equals("otr")) {
+							message.setEncryption(Message.ENCRYPTION_OTR);
 						} else {
-							this.file.setKey(key);
+							message.setEncryption(Message.ENCRYPTION_PGP);
 						}
 					}
-					this.file.setExpectedSize(size);
 				} else {
-					this.sendCancel();
-					this.cancel();
+					message.setType(Message.TYPE_FILE);
+				}
+				if (message.getType() == Message.TYPE_FILE) {
+					String suffix = "";
+					if (!fileNameElement.getContent().isEmpty()) {
+						String parts[] = fileNameElement.getContent().split("/");
+						suffix = parts[parts.length - 1];
+					}
+					message.setRelativeFilePath(message.getUuid()+"_"+suffix);
 				}
+				long size = Long.parseLong(fileSize.getContent());
+				message.setBody(Long.toString(size));
+				conversation.add(message);
+				mXmppConnectionService.updateConversationUi();
+				if (size <= this.mJingleConnectionManager
+						.getAutoAcceptFileSize()) {
+					Log.d(Config.LOGTAG, "auto accepting file from "
+							+ packet.getFrom());
+					this.acceptedAutomatically = true;
+					this.sendAccept();
+				} else {
+					message.markUnread();
+					Log.d(Config.LOGTAG,
+							"not auto accepting new file offer with size: "
+									+ size
+									+ " allowed size:"
+									+ this.mJingleConnectionManager
+											.getAutoAcceptFileSize());
+					this.mXmppConnectionService.getNotificationService()
+							.push(message);
+				}
+				this.file = this.mXmppConnectionService.getFileBackend()
+						.getFile(message, false);
+				if (message.getEncryption() == Message.ENCRYPTION_OTR) {
+					byte[] key = conversation.getSymmetricKey();
+					if (key == null) {
+						this.sendCancel();
+						this.cancel();
+						return;
+					} else {
+						this.file.setKey(key);
+					}
+				}
+				this.file.setExpectedSize(size);
 			} else {
 				this.sendCancel();
 				this.cancel();
@@ -354,7 +357,7 @@ public class JingleConnection implements Downloadable {
 		this.mXmppConnectionService.markMessage(this.message, Message.STATUS_OFFERED);
 		JinglePacket packet = this.bootstrapPacket("session-initiate");
 		Content content = new Content(this.contentCreator, this.contentName);
-		if (message.getType() == Message.TYPE_IMAGE) {
+		if (message.getType() == Message.TYPE_IMAGE || message.getType() == Message.TYPE_FILE) {
 			content.setTransportId(this.transportId);
 			this.file = this.mXmppConnectionService.getFileBackend().getFile(
 					message, false);
@@ -856,6 +859,14 @@ public class JingleConnection implements Downloadable {
 		return null;
 	}
 
+	public void updateProgress(int i) {
+		this.mProgress = i;
+		if (SystemClock.elapsedRealtime() - this.mLastGuiRefresh > 1000) {
+			this.mLastGuiRefresh = SystemClock.elapsedRealtime();
+			mXmppConnectionService.updateConversationUi();
+		}
+	}
+
 	interface OnProxyActivated {
 		public void success();
 
@@ -900,4 +911,14 @@ public class JingleConnection implements Downloadable {
 			return 0;
 		}
 	}
+
+	@Override
+	public int getProgress() {
+		return this.mProgress;
+	}
+
+	@Override
+	public String getMimeType() {
+		return this.file.getMimeType();
+	}
 }

src/main/java/eu/siacs/conversations/xmpp/jingle/JingleSocks5Transport.java πŸ”—

@@ -15,6 +15,7 @@ import eu.siacs.conversations.utils.CryptoHelper;
 
 public class JingleSocks5Transport extends JingleTransport {
 	private JingleCandidate candidate;
+	private JingleConnection connection;
 	private String destination;
 	private OutputStream outputStream;
 	private InputStream inputStream;
@@ -25,6 +26,7 @@ public class JingleSocks5Transport extends JingleTransport {
 	public JingleSocks5Transport(JingleConnection jingleConnection,
 			JingleCandidate candidate) {
 		this.candidate = candidate;
+		this.connection = jingleConnection;
 		try {
 			MessageDigest mDigest = MessageDigest.getInstance("SHA-1");
 			StringBuilder destBuilder = new StringBuilder();
@@ -102,11 +104,15 @@ public class JingleSocks5Transport extends JingleTransport {
 						callback.onFileTransferAborted();
 						return;
 					}
+					long size = file.getSize();
+					double transmitted = 0;
 					int count;
 					byte[] buffer = new byte[8192];
 					while ((count = fileInputStream.read(buffer)) > 0) {
 						outputStream.write(buffer, 0, count);
 						digest.update(buffer, 0, count);
+						transmitted += count;
+						connection.updateProgress((int) (((transmitted) / size) * 100));
 					}
 					outputStream.flush();
 					file.setSha1Sum(CryptoHelper.bytesToHex(digest.digest()));
@@ -151,6 +157,7 @@ public class JingleSocks5Transport extends JingleTransport {
 						callback.onFileTransferAborted();
 						return;
 					}
+					double size = file.getExpectedSize();
 					long remainingSize = file.getExpectedSize();
 					byte[] buffer = new byte[8192];
 					int count = buffer.length;
@@ -164,6 +171,7 @@ public class JingleSocks5Transport extends JingleTransport {
 							digest.update(buffer, 0, count);
 							remainingSize -= count;
 						}
+						connection.updateProgress((int) (((size - remainingSize) / size) * 100));
 					}
 					fileOutputStream.flush();
 					fileOutputStream.close();

src/main/res/menu/attachment_choices.xml πŸ”—

@@ -9,7 +9,6 @@
         android:title="@string/attach_take_picture"/>
     <item
         android:id="@+id/attach_record_voice"
-        android:title="@string/attach_record_voice"
-        android:visible="false"/>
+        android:title="@string/choose_file"/>
 
 </menu>

src/main/res/values/strings.xml πŸ”—

@@ -58,7 +58,7 @@
     <string name="add_contact">Add contact</string>
     <string name="send_failed">delivery failed</string>
     <string name="send_rejected">rejected</string>
-    <string name="receiving_image">Receiving image file. Please wait…</string>
+    <string name="receiving_image">Receiving image file (%1$d%%)</string>
     <string name="preparing_image">Preparing image for transmission</string>
     <string name="action_clear_history">Clear history</string>
     <string name="clear_conversation_history">Clear Conversation History</string>
@@ -311,4 +311,8 @@
     <string name="scan_qr_code">Scan QR code</string>
     <string name="show_qr_code">Show QR code</string>
     <string name="account_details">Account details</string>
+    <string name="choose_file">Choose file</string>
+    <string name="receiving_file">Receiving %1$s file (%2$d%%)</string>
+    <string name="download_file">Download %s file</string>
+    <string name="open_file">Open %s file</string>
 </resources>