From 4796902b9cbc109fbe50e3aaa44dd38a54b49db8 Mon Sep 17 00:00:00 2001
From: Torsten Grote <t@grobox.de>
Date: Fri, 23 Nov 2018 18:58:32 -0200
Subject: [PATCH] [android] store attachments and actually attach them to sent
 messages

---
 .../conversation/AttachmentController.java    |  23 ++-
 .../conversation/ConversationActivity.java    |  64 +------
 .../conversation/ConversationViewModel.java   | 156 +++++++++++++++++-
 .../briar/android/util/UiUtils.java           |  18 ++
 .../briar/api/messaging/MessagingManager.java |   4 +-
 .../briar/messaging/MessagingManagerImpl.java |   4 +-
 6 files changed, 207 insertions(+), 62 deletions(-)

diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentController.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentController.java
index 2d85e26687..b1a37456f5 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentController.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentController.java
@@ -90,10 +90,16 @@ class AttachmentController {
 		return attachments;
 	}
 
+	/**
+	 * Creates {@link AttachmentItem}s from the passed headers and Attachments.
+	 * Note: This marks the {@link Attachment}'s {@link InputStream}
+	 * and closes the streams.
+	 */
 	List<AttachmentItem> getAttachmentItems(
 			List<Pair<AttachmentHeader, Attachment>> attachments) {
 		List<AttachmentItem> items = new ArrayList<>(attachments.size());
 		for (Pair<AttachmentHeader, Attachment> a : attachments) {
+			a.getSecond().getStream().mark(Integer.MAX_VALUE);
 			AttachmentItem item =
 					getAttachmentItem(a.getFirst(), a.getSecond());
 			items.add(item);
@@ -101,12 +107,18 @@ class AttachmentController {
 		return items;
 	}
 
-	private AttachmentItem getAttachmentItem(AttachmentHeader h, Attachment a) {
+	/**
+	 * Creates an {@link AttachmentItem} from the {@link Attachment}'s
+	 * {@link InputStream}.
+	 * Note: Requires a resettable InputStream
+	 *       with the beginning already marked.
+	 *       The stream will be closed when this method returns.
+	 */
+	AttachmentItem getAttachmentItem(AttachmentHeader h, Attachment a) {
 		MessageId messageId = h.getMessageId();
 		Size size = new Size();
 
 		InputStream is = a.getStream();
-		is.mark(Integer.MAX_VALUE);
 		try {
 			// use exif to get size
 			if (h.getContentType().equals("image/jpeg")) {
@@ -178,6 +190,13 @@ class AttachmentController {
 				options.outMimeType);
 	}
 
+	static String getContentTypeFromBitmap(InputStream is) {
+		BitmapFactory.Options options = new BitmapFactory.Options();
+		options.inJustDecodeBounds = true;
+		BitmapFactory.decodeStream(is, null, options);
+		return options.outMimeType;
+	}
+
 	private Size getThumbnailSize(int width, int height, String mimeType) {
 		float widthPercentage = maxWidth / (float) width;
 		float heightPercentage = maxHeight / (float) height;
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationActivity.java
index 3d3611f906..9f48b56283 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationActivity.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationActivity.java
@@ -29,7 +29,6 @@ import android.widget.ImageView;
 import android.widget.TextView;
 import android.widget.Toast;
 
-import org.briarproject.bramble.api.FormatException;
 import org.briarproject.bramble.api.Pair;
 import org.briarproject.bramble.api.contact.ContactId;
 import org.briarproject.bramble.api.contact.ContactManager;
@@ -49,7 +48,6 @@ import org.briarproject.bramble.api.plugin.event.ContactDisconnectedEvent;
 import org.briarproject.bramble.api.settings.Settings;
 import org.briarproject.bramble.api.settings.SettingsManager;
 import org.briarproject.bramble.api.sync.GroupId;
-import org.briarproject.bramble.api.sync.Message;
 import org.briarproject.bramble.api.sync.MessageId;
 import org.briarproject.bramble.api.sync.event.MessagesAckedEvent;
 import org.briarproject.bramble.api.sync.event.MessagesSentEvent;
@@ -83,7 +81,6 @@ import org.briarproject.briar.api.introduction.IntroductionManager;
 import org.briarproject.briar.api.messaging.Attachment;
 import org.briarproject.briar.api.messaging.AttachmentHeader;
 import org.briarproject.briar.api.messaging.MessagingManager;
-import org.briarproject.briar.api.messaging.PrivateMessage;
 import org.briarproject.briar.api.messaging.PrivateMessageFactory;
 import org.briarproject.briar.api.messaging.PrivateMessageHeader;
 import org.briarproject.briar.api.privategroup.invitation.GroupInvitationManager;
@@ -196,8 +193,6 @@ public class ConversationActivity extends BriarActivity
 	ViewModelProvider.Factory viewModelFactory;
 
 	private volatile ContactId contactId;
-	@Nullable
-	private volatile GroupId messagingGroupId;
 
 	private final Observer<String> contactNameObserver = name -> {
 		requireNonNull(name);
@@ -245,6 +240,8 @@ public class ConversationActivity extends BriarActivity
 			requireNonNull(deleted);
 			if (deleted) finish();
 		});
+		viewModel.getAddedPrivateMessage()
+				.observe(this, this::onAddedPrivateMessage);
 
 		setTransitionName(toolbarAvatar, getAvatarTransitionName(contactId));
 		setTransitionName(toolbarStatus, getBulbTransitionName(contactId));
@@ -600,16 +597,11 @@ public class ConversationActivity extends BriarActivity
 
 	@Override
 	public void onSendClick(@Nullable String text, List<Uri> imageUris) {
-		if (!imageUris.isEmpty()) {
-			Toast.makeText(this, "Not yet implemented.", LENGTH_LONG).show();
-			textInputView.clearText();
-			return;
-		}
-		if (isNullOrEmpty(text)) throw new AssertionError();
+		if (isNullOrEmpty(text) && imageUris.isEmpty())
+			throw new AssertionError();
 		long timestamp = System.currentTimeMillis();
 		timestamp = Math.max(timestamp, getMinTimestampForNewMessage());
-		if (messagingGroupId == null) loadGroupId(text, timestamp);
-		else createMessage(text, timestamp);
+		viewModel.sendMessage(text, imageUris, timestamp);
 		textInputView.clearText();
 	}
 
@@ -619,48 +611,10 @@ public class ConversationActivity extends BriarActivity
 		return item == null ? 0 : item.getTime() + 1;
 	}
 
-	private void loadGroupId(String text, long timestamp) {
-		runOnDbThread(() -> {
-			try {
-				messagingGroupId =
-						messagingManager.getConversationId(contactId);
-				createMessage(text, timestamp);
-			} catch (DbException e) {
-				logException(LOG, WARNING, e);
-			}
-
-		});
-	}
-
-	private void createMessage(String text, long timestamp) {
-		cryptoExecutor.execute(() -> {
-			try {
-				//noinspection ConstantConditions init in loadGroupId()
-				storeMessage(privateMessageFactory.createPrivateMessage(
-						messagingGroupId, timestamp, text, emptyList()), text);
-			} catch (FormatException e) {
-				throw new RuntimeException(e);
-			}
-		});
-	}
-
-	private void storeMessage(PrivateMessage m, String text) {
-		runOnDbThread(() -> {
-			try {
-				long start = now();
-				messagingManager.addLocalMessage(m);
-				logDuration(LOG, "Storing message", start);
-				Message message = m.getMessage();
-				PrivateMessageHeader h = new PrivateMessageHeader(
-						message.getId(), message.getGroupId(),
-						message.getTimestamp(), true, false, false, false,
-						true, emptyList());
-				textCache.put(message.getId(), text);
-				addConversationItem(h.accept(visitor));
-			} catch (DbException e) {
-				logException(LOG, WARNING, e);
-			}
-		});
+	private void onAddedPrivateMessage(@Nullable PrivateMessageHeader h) {
+		if (h == null) return;
+		addConversationItem(h.accept(visitor));
+		viewModel.onAddedPrivateMessageSeen();
 	}
 
 	private void askToRemoveContact() {
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationViewModel.java
index 3a8e6e3640..b4e158253c 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationViewModel.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationViewModel.java
@@ -5,19 +5,37 @@ import android.arch.lifecycle.AndroidViewModel;
 import android.arch.lifecycle.LiveData;
 import android.arch.lifecycle.MutableLiveData;
 import android.arch.lifecycle.Transformations;
+import android.content.ContentResolver;
+import android.net.Uri;
 import android.support.annotation.Nullable;
+import android.support.annotation.UiThread;
 
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.Pair;
 import org.briarproject.bramble.api.contact.Contact;
 import org.briarproject.bramble.api.contact.ContactId;
 import org.briarproject.bramble.api.contact.ContactManager;
+import org.briarproject.bramble.api.crypto.CryptoExecutor;
 import org.briarproject.bramble.api.db.DatabaseExecutor;
 import org.briarproject.bramble.api.db.DbException;
 import org.briarproject.bramble.api.db.NoSuchContactException;
 import org.briarproject.bramble.api.identity.AuthorId;
 import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.Message;
 import org.briarproject.briar.android.util.UiUtils;
+import org.briarproject.briar.api.messaging.Attachment;
+import org.briarproject.briar.api.messaging.AttachmentHeader;
 import org.briarproject.briar.api.messaging.MessagingManager;
+import org.briarproject.briar.api.messaging.PrivateMessage;
+import org.briarproject.briar.api.messaging.PrivateMessageFactory;
+import org.briarproject.briar.api.messaging.PrivateMessageHeader;
 
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.concurrent.Executor;
 import java.util.logging.Logger;
 
@@ -29,6 +47,8 @@ import static java.util.logging.Logger.getLogger;
 import static org.briarproject.bramble.util.LogUtils.logDuration;
 import static org.briarproject.bramble.util.LogUtils.logException;
 import static org.briarproject.bramble.util.LogUtils.now;
+import static org.briarproject.briar.android.conversation.AttachmentController.getContentTypeFromBitmap;
+import static org.briarproject.briar.android.util.UiUtils.observeForeverOnce;
 
 @NotNullByDefault
 public class ConversationViewModel extends AndroidViewModel {
@@ -38,7 +58,11 @@ public class ConversationViewModel extends AndroidViewModel {
 
 	@DatabaseExecutor
 	private final Executor dbExecutor;
+	@CryptoExecutor
+	private final Executor cryptoExecutor;
+	private final MessagingManager messagingManager;
 	private final ContactManager contactManager;
+	private final PrivateMessageFactory privateMessageFactory;
 	private final AttachmentController attachmentController;
 
 	@Nullable
@@ -50,14 +74,24 @@ public class ConversationViewModel extends AndroidViewModel {
 			Transformations.map(contact, UiUtils::getContactDisplayName);
 	private final MutableLiveData<Boolean> contactDeleted =
 			new MutableLiveData<>();
+	private final MutableLiveData<GroupId> messagingGroupId =
+			new MutableLiveData<>();
+	private final MutableLiveData<PrivateMessageHeader> addedHeader =
+			new MutableLiveData<>();
 
 	@Inject
 	ConversationViewModel(Application application,
 			@DatabaseExecutor Executor dbExecutor,
-			ContactManager contactManager, MessagingManager messagingManager) {
+			@CryptoExecutor Executor cryptoExecutor,
+			MessagingManager messagingManager,
+			ContactManager contactManager,
+			PrivateMessageFactory privateMessageFactory) {
 		super(application);
 		this.dbExecutor = dbExecutor;
+		this.cryptoExecutor = cryptoExecutor;
+		this.messagingManager = messagingManager;
 		this.contactManager = contactManager;
+		this.privateMessageFactory = privateMessageFactory;
 		this.attachmentController = new AttachmentController(messagingManager,
 				application.getResources());
 		contactDeleted.setValue(false);
@@ -100,6 +134,122 @@ public class ConversationViewModel extends AndroidViewModel {
 		});
 	}
 
+	void sendMessage(@Nullable String text, List<Uri> uris, long timestamp) {
+		if (messagingGroupId.getValue() == null) loadGroupId();
+		observeForeverOnce(messagingGroupId, groupId -> {
+			if (groupId == null) return;
+			// calls through to creating and storing the message
+			// (wouldn't Kotlin's co-routines be nice here?)
+			storeAttachments(groupId, text, uris, timestamp);
+		});
+	}
+
+	private void loadGroupId() {
+		if (contactId == null) throw new IllegalStateException();
+		dbExecutor.execute(() -> {
+			try {
+				messagingGroupId.postValue(
+						messagingManager.getConversationId(contactId));
+			} catch (DbException e) {
+				logException(LOG, WARNING, e);
+			}
+		});
+	}
+
+	private void storeAttachments(GroupId groupId, @Nullable String text,
+			List<Uri> uris, long timestamp) {
+		dbExecutor.execute(() -> {
+			long start = now();
+			List<AttachmentHeader> attachments = new ArrayList<>();
+			List<AttachmentItem> items = new ArrayList<>();
+			for (Uri uri : uris) {
+				Pair<AttachmentHeader, AttachmentItem> pair =
+						createAttachmentHeader(groupId, uri, timestamp);
+				if (pair == null) continue;
+				attachments.add(pair.getFirst());
+				items.add(pair.getSecond());
+			}
+			logDuration(LOG, "Storing attachments", start);
+			createMessage(groupId, text, attachments, items, timestamp);
+		});
+	}
+
+	@Nullable
+	@DatabaseExecutor
+	private Pair<AttachmentHeader, AttachmentItem> createAttachmentHeader(
+			GroupId groupId, Uri uri, long timestamp) {
+		InputStream is = null;
+		try {
+			ContentResolver contentResolver =
+					getApplication().getContentResolver();
+			is = contentResolver.openInputStream(uri);
+			if (is == null) throw new IOException();
+			is = new BufferedInputStream(is);  // adds support for reset()
+			is.mark(Integer.MAX_VALUE);
+			String contentType = contentResolver.getType(uri);
+			if (contentType == null) contentType = getContentTypeFromBitmap(is);
+			AttachmentHeader h = messagingManager
+					.addLocalAttachment(groupId, timestamp, contentType, is);
+			AttachmentItem item = attachmentController
+					.getAttachmentItem(h, new Attachment(is));
+			return new Pair<>(h, item);
+		} catch (DbException | IOException e) {
+			logException(LOG, WARNING, e);
+			return null;
+		} finally {
+			if (is != null) {
+				try {
+					is.close();
+				} catch (IOException e) {
+					logException(LOG, WARNING, e);
+				}
+			}
+		}
+	}
+
+	private void createMessage(GroupId groupId, @Nullable String text,
+			List<AttachmentHeader> attachments, List<AttachmentItem> aItems,
+			long timestamp) {
+		cryptoExecutor.execute(() -> {
+			try {
+				// TODO remove when text can be null in the backend
+				String msgText = text == null ? "null" : text;
+				PrivateMessage pm = privateMessageFactory
+						.createPrivateMessage(groupId, timestamp, msgText,
+								attachments);
+				attachmentController.put(pm.getMessage().getId(), aItems);
+				storeMessage(pm, msgText, attachments);
+			} catch (FormatException e) {
+				throw new RuntimeException(e);
+			}
+		});
+	}
+
+	private void storeMessage(PrivateMessage m, @Nullable String text,
+			List<AttachmentHeader> attachments) {
+		dbExecutor.execute(() -> {
+			try {
+				long start = now();
+				messagingManager.addLocalMessage(m);
+				logDuration(LOG, "Storing message", start);
+				Message message = m.getMessage();
+				PrivateMessageHeader h = new PrivateMessageHeader(
+						message.getId(), message.getGroupId(),
+						message.getTimestamp(), true, true, false, false,
+						text != null, attachments);
+				// TODO add text to cache when available here
+				addedHeader.postValue(h);
+			} catch (DbException e) {
+				logException(LOG, WARNING, e);
+			}
+		});
+	}
+
+	@UiThread
+	void onAddedPrivateMessageSeen() {
+		addedHeader.setValue(null);
+	}
+
 	AttachmentController getAttachmentController() {
 		return attachmentController;
 	}
@@ -120,4 +270,8 @@ public class ConversationViewModel extends AndroidViewModel {
 		return contactDeleted;
 	}
 
+	LiveData<PrivateMessageHeader> getAddedPrivateMessage() {
+		return addedHeader;
+	}
+
 }
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java b/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java
index fcd7fcb146..b74e8a216e 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java
@@ -354,4 +354,22 @@ public class UiUtils {
 		});
 	}
 
+	/**
+	 * Same as {@link #observeOnce(LiveData, LifecycleOwner, Observer)},
+	 * but without a {@link LifecycleOwner}.
+	 *
+	 * Warning: Do NOT call from objects that have a lifecycle.
+	 */
+	@MainThread
+	public static <T> void observeForeverOnce(LiveData<T> liveData,
+			Observer<T> observer) {
+		liveData.observeForever(new Observer<T>() {
+			@Override
+			public void onChanged(@Nullable T t) {
+				observer.onChanged(t);
+				liveData.removeObserver(this);
+			}
+		});
+	}
+
 }
diff --git a/briar-api/src/main/java/org/briarproject/briar/api/messaging/MessagingManager.java b/briar-api/src/main/java/org/briarproject/briar/api/messaging/MessagingManager.java
index d7f4997a7e..91b8d866c3 100644
--- a/briar-api/src/main/java/org/briarproject/briar/api/messaging/MessagingManager.java
+++ b/briar-api/src/main/java/org/briarproject/briar/api/messaging/MessagingManager.java
@@ -8,7 +8,7 @@ import org.briarproject.bramble.api.sync.GroupId;
 import org.briarproject.bramble.api.sync.MessageId;
 import org.briarproject.briar.api.conversation.ConversationManager.ConversationClient;
 
-import java.nio.ByteBuffer;
+import java.io.InputStream;
 
 @NotNullByDefault
 public interface MessagingManager extends ConversationClient {
@@ -37,7 +37,7 @@ public interface MessagingManager extends ConversationClient {
 	 * Stores a local attachment message.
 	 */
 	AttachmentHeader addLocalAttachment(GroupId groupId, long timestamp,
-			String contentType, ByteBuffer data) throws DbException;
+			String contentType, InputStream is) throws DbException;
 
 	/**
 	 * Returns the ID of the contact with the given private conversation.
diff --git a/briar-core/src/main/java/org/briarproject/briar/messaging/MessagingManagerImpl.java b/briar-core/src/main/java/org/briarproject/briar/messaging/MessagingManagerImpl.java
index bc92e69520..d8c06c4aa1 100644
--- a/briar-core/src/main/java/org/briarproject/briar/messaging/MessagingManagerImpl.java
+++ b/briar-core/src/main/java/org/briarproject/briar/messaging/MessagingManagerImpl.java
@@ -32,7 +32,7 @@ import org.briarproject.briar.api.messaging.PrivateMessageHeader;
 import org.briarproject.briar.api.messaging.event.PrivateMessageReceivedEvent;
 import org.briarproject.briar.client.ConversationClientImpl;
 
-import java.nio.ByteBuffer;
+import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Map;
@@ -152,7 +152,7 @@ class MessagingManagerImpl extends ConversationClientImpl
 
 	@Override
 	public AttachmentHeader addLocalAttachment(GroupId groupId, long timestamp,
-			String contentType, ByteBuffer data) {
+			String contentType, InputStream is) {
 		// TODO add real implementation
 		byte[] b = new byte[MessageId.LENGTH];
 		new Random().nextBytes(b);
-- 
GitLab