diff --git a/briar-android/src/main/java/org/briarproject/briar/android/blog/ReblogFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/blog/ReblogFragment.java
index 94f75cb8c52631ac0772a69e64a8f115ce89a82d..190d957b7123e04067d6a5672ff6ac4e5f35007f 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/blog/ReblogFragment.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/blog/ReblogFragment.java
@@ -20,6 +20,7 @@ import org.briarproject.briar.android.controller.handler.UiResultExceptionHandle
 import org.briarproject.briar.android.fragment.BaseFragment;
 import org.briarproject.briar.android.view.TextInputView;
 import org.briarproject.briar.android.view.TextInputView.SendListener;
+import org.briarproject.briar.android.view.TextSendController;
 
 import java.util.List;
 
@@ -81,6 +82,9 @@ public class ReblogFragment extends BaseFragment implements SendListener {
 		View v = inflater.inflate(R.layout.fragment_reblog, container, false);
 		ui = new ViewHolder(v);
 		ui.post.setTransitionName(postId);
+		TextSendController sendController =
+				new TextSendController(ui.input, this, true);
+		ui.input.setSendController(sendController);
 		ui.input.setEnabled(false);
 		ui.input.setMaxTextLength(MAX_BLOG_POST_TEXT_LENGTH);
 		showProgressBar();
@@ -117,7 +121,6 @@ public class ReblogFragment extends BaseFragment implements SendListener {
 		ui.post.bindItem(item);
 		ui.post.hideReblogButton();
 
-		ui.input.setListener(this);
 		ui.input.setEnabled(true);
 		ui.scrollView.post(() -> ui.scrollView.fullScroll(FOCUS_DOWN));
 	}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/blog/WriteBlogPostActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/blog/WriteBlogPostActivity.java
index e26e2e05e2bbc1cac575a2d0fdb3ebc1a5485db1..e67fc2a6968d06b3d2c5b90f8c16b8600f6841f2 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/blog/WriteBlogPostActivity.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/blog/WriteBlogPostActivity.java
@@ -22,6 +22,7 @@ import org.briarproject.briar.android.activity.ActivityComponent;
 import org.briarproject.briar.android.activity.BriarActivity;
 import org.briarproject.briar.android.view.TextInputView;
 import org.briarproject.briar.android.view.TextInputView.SendListener;
+import org.briarproject.briar.android.view.TextSendController;
 import org.briarproject.briar.api.android.AndroidNotificationManager;
 import org.briarproject.briar.api.blog.BlogManager;
 import org.briarproject.briar.api.blog.BlogPost;
@@ -75,8 +76,10 @@ public class WriteBlogPostActivity extends BriarActivity
 		setContentView(R.layout.activity_write_blog_post);
 
 		input = findViewById(R.id.textInput);
+		TextSendController sendController =
+				new TextSendController(input, this, false);
+		input.setSendController(sendController);
 		input.setMaxTextLength(MAX_BLOG_POST_TEXT_LENGTH);
-		input.setListener(this);
 
 		progressBar = findViewById(R.id.progressBar);
 	}
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 5d53b8d19d97791787ed6dea9baa090f6091ae78..167b7606a9f1ccb1c919480a94c35e5907bcfc3d 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
@@ -63,9 +63,11 @@ import org.briarproject.briar.android.forum.ForumActivity;
 import org.briarproject.briar.android.introduction.IntroductionActivity;
 import org.briarproject.briar.android.privategroup.conversation.GroupActivity;
 import org.briarproject.briar.android.view.BriarRecyclerView;
+import org.briarproject.briar.android.view.TextAttachmentController;
 import org.briarproject.briar.android.view.TextInputView;
 import org.briarproject.briar.android.view.TextInputView.AttachImageListener;
 import org.briarproject.briar.android.view.TextInputView.SendListener;
+import org.briarproject.briar.android.view.TextSendController;
 import org.briarproject.briar.api.android.AndroidNotificationManager;
 import org.briarproject.briar.api.blog.BlogSharingManager;
 import org.briarproject.briar.api.client.ProtocolStateException;
@@ -166,6 +168,7 @@ public class ConversationActivity extends BriarActivity
 	private BriarRecyclerView list;
 	private LinearLayoutManager layoutManager;
 	private TextInputView textInputView;
+	private TextSendController sendController;
 
 	// Fields that are accessed from background threads must be volatile
 	@Inject
@@ -255,12 +258,15 @@ public class ConversationActivity extends BriarActivity
 		list.setEmptyText(getString(R.string.no_private_messages));
 
 		textInputView = findViewById(R.id.text_input_container);
-		textInputView.setMaxTextLength(MAX_PRIVATE_MESSAGE_TEXT_LENGTH);
-		textInputView.setEnabled(false);
-		textInputView.setListener(this);
 		if (FEATURE_FLAG_IMAGE_ATTACHMENTS) {
-			textInputView.setAttachImageListener(this, getWindowManager());
+			sendController = new TextAttachmentController(textInputView, this,
+					this, getWindowManager());
+		} else {
+			sendController = new TextSendController(textInputView, this, false);
 		}
+		textInputView.setSendController(sendController);
+		textInputView.setMaxTextLength(MAX_PRIVATE_MESSAGE_TEXT_LENGTH);
+		textInputView.setEnabled(false);
 	}
 
 	@Override
@@ -278,7 +284,8 @@ public class ConversationActivity extends BriarActivity
 			snackbar.getView().setBackgroundResource(R.color.briar_primary);
 			snackbar.show();
 		} else if (request == REQUEST_ATTACH_IMAGE && result == RESULT_OK) {
-			textInputView.onImageReceived(data);
+			// remove cast when removing FEATURE_FLAG_IMAGE_ATTACHMENTS
+			((TextAttachmentController) sendController).onImageReceived(data);
 		}
 	}
 
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/introduction/IntroductionMessageFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/introduction/IntroductionMessageFragment.java
index 651a05413e23e01a93851cc29f55fdd8e9b676bb..1bfaff70eac31e0e8e8d60785817f02caff34dc6 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/introduction/IntroductionMessageFragment.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/introduction/IntroductionMessageFragment.java
@@ -24,6 +24,7 @@ import org.briarproject.briar.android.activity.ActivityComponent;
 import org.briarproject.briar.android.fragment.BaseFragment;
 import org.briarproject.briar.android.view.TextInputView;
 import org.briarproject.briar.android.view.TextInputView.SendListener;
+import org.briarproject.briar.android.view.TextSendController;
 import org.briarproject.briar.api.introduction.IntroductionManager;
 
 import java.util.List;
@@ -102,6 +103,9 @@ public class IntroductionMessageFragment extends BaseFragment
 		View v = inflater.inflate(R.layout.introduction_message, container,
 				false);
 		ui = new ViewHolder(v);
+		TextSendController sendController =
+				new TextSendController(ui.message, this, true);
+		ui.message.setSendController(sendController);
 		ui.message.setMaxTextLength(MAX_INTRODUCTION_TEXT_LENGTH);
 		ui.message.setEnabled(false);
 
@@ -162,9 +166,6 @@ public class IntroductionMessageFragment extends BaseFragment
 			ui.progressBar.setVisibility(GONE);
 
 			if (possible) {
-				// set button action
-				ui.message.setListener(IntroductionMessageFragment.this);
-
 				// show views
 				ui.notPossible.setVisibility(GONE);
 				ui.message.setVisibility(VISIBLE);
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/sharing/BaseMessageFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/sharing/BaseMessageFragment.java
index 7f732a7609b4f6b16851b11047db56b1706c3656..c05a0f357ea02c83006eb6d204c6b764eb3f936e 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/sharing/BaseMessageFragment.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/sharing/BaseMessageFragment.java
@@ -18,6 +18,7 @@ import org.briarproject.briar.R;
 import org.briarproject.briar.android.fragment.BaseFragment;
 import org.briarproject.briar.android.view.LargeTextInputView;
 import org.briarproject.briar.android.view.TextInputView.SendListener;
+import org.briarproject.briar.android.view.TextSendController;
 
 import java.util.List;
 
@@ -44,10 +45,12 @@ public abstract class BaseMessageFragment extends BaseFragment
 		View v = inflater.inflate(R.layout.fragment_message, container,
 				false);
 		message = v.findViewById(R.id.messageView);
+		TextSendController sendController =
+				new TextSendController(message, this, true);
+		message.setSendController(sendController);
 		message.setMaxTextLength(listener.getMaximumTextLength());
 		message.setButtonText(getString(getButtonText()));
 		message.setHint(getHintText());
-		message.setListener(this);
 
 		return v;
 	}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/threaded/ThreadListActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/threaded/ThreadListActivity.java
index 9429b0ed8628f64d19eac97c93aa303e19da9ad3..cc63423ab46a8ec8c1edb702b050ce08e1407bfd 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/threaded/ThreadListActivity.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/threaded/ThreadListActivity.java
@@ -29,6 +29,7 @@ import org.briarproject.briar.android.threaded.ThreadListController.ThreadListLi
 import org.briarproject.briar.android.view.BriarRecyclerView;
 import org.briarproject.briar.android.view.TextInputView;
 import org.briarproject.briar.android.view.TextInputView.SendListener;
+import org.briarproject.briar.android.view.TextSendController;
 import org.briarproject.briar.android.view.UnreadMessageButton;
 import org.briarproject.briar.api.client.NamedGroup;
 import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout;
@@ -88,8 +89,10 @@ public abstract class ThreadListActivity<G extends NamedGroup, I extends ThreadI
 		getController().setGroupId(groupId);
 
 		textInput = findViewById(R.id.text_input_container);
+		TextSendController sendController =
+				new TextSendController(textInput, this, false);
+		textInput.setSendController(sendController);
 		textInput.setMaxTextLength(getMaxTextLength());
-		textInput.setListener(this);
 		list = findViewById(R.id.list);
 		layoutManager = new LinearLayoutManager(this);
 		// FIXME pre-fetching messes with read state, find better solution #1289
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/view/LargeTextInputView.java b/briar-android/src/main/java/org/briarproject/briar/android/view/LargeTextInputView.java
index 1427f33efa3afd12ec6afb46a6d5955a50137191..87b9b9decb20cbbb685f62c843b7b7540e7711c8 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/view/LargeTextInputView.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/view/LargeTextInputView.java
@@ -71,7 +71,7 @@ public class LargeTextInputView extends TextInputView {
 	}
 
 	public void setButtonText(String text) {
-		((Button) sendButton).setText(text);
+		((Button) findViewById(R.id.btn_send)).setText(text);
 	}
 
 }
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/view/TextAttachmentController.java b/briar-android/src/main/java/org/briarproject/briar/android/view/TextAttachmentController.java
index 04571d012868982c44c54155d230643c2c04c43a..b61d76452ad0b5bad30eb1d9ab7ad0215e2b2f04 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/view/TextAttachmentController.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/view/TextAttachmentController.java
@@ -6,6 +6,7 @@ import android.graphics.Bitmap;
 import android.net.Uri;
 import android.os.Parcel;
 import android.os.Parcelable;
+import android.support.annotation.NonNull;
 import android.support.annotation.Nullable;
 import android.support.annotation.UiThread;
 import android.support.design.widget.FloatingActionButton;
@@ -13,7 +14,6 @@ import android.support.v4.view.AbsSavedState;
 import android.support.v7.graphics.Palette;
 import android.support.v7.widget.AppCompatImageButton;
 import android.util.DisplayMetrics;
-import android.view.View;
 import android.view.ViewGroup;
 import android.view.ViewGroup.LayoutParams;
 import android.view.WindowManager;
@@ -28,6 +28,7 @@ import com.bumptech.glide.request.target.Target;
 import org.briarproject.briar.R;
 import org.briarproject.briar.android.conversation.glide.GlideApp;
 import org.briarproject.briar.android.view.TextInputView.AttachImageListener;
+import org.briarproject.briar.android.view.TextInputView.SendListener;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -53,21 +54,21 @@ import static java.util.Collections.singletonList;
 import static java.util.Objects.requireNonNull;
 
 @UiThread
-class TextAttachmentController extends TextSendController {
+public class TextAttachmentController extends TextSendController {
 
 	private final AppCompatImageButton imageButton;
 	private final ViewGroup imageLayout;
 	private final ImageView imageView;
 
-	@Nullable
-	private AttachImageListener imageListener;
+	private final AttachImageListener imageListener;
 
 	private CharSequence textHint;
 	private List<Uri> imageUris = emptyList();
 
-	TextAttachmentController(View v, View sendButton,
-			TextInputController textInput) {
-		super(sendButton, textInput, true);
+	public TextAttachmentController(TextInputView v, SendListener listener,
+			AttachImageListener imageListener, WindowManager windowManager) {
+		super(v, listener, true);
+		this.imageListener = imageListener;
 
 		imageLayout = v.findViewById(R.id.imageLayout);
 		imageView = v.findViewById(R.id.imageView);
@@ -82,11 +83,7 @@ class TextAttachmentController extends TextSendController {
 			textInput.clearText();
 			reset();
 		});
-	}
 
-	public void setAttachImageListener(AttachImageListener imageListener,
-			WindowManager windowManager) {
-		this.imageListener = imageListener;
 		// set preview size based on screen height
 		DisplayMetrics displayMetrics = new DisplayMetrics();
 		windowManager.getDefaultDisplay().getMetrics(displayMetrics);
@@ -98,20 +95,16 @@ class TextAttachmentController extends TextSendController {
 	}
 
 	@Override
-	public void onTextValidityChanged(boolean isEmpty) {
+	public void onTextIsEmptyChanged(boolean isEmpty) {
 		if (imageUris.isEmpty()) showImageButton(isEmpty);
 	}
 
 	@Override
 	void onSendButtonClicked() {
-		if (listener != null) {
-			if (textInput.isTooLong()) {
-				textInput.showError();
-				return;
-			}
+		if (canSend()) {
 			listener.onSendClick(textInput.getText(), imageUris);
+			reset();
 		}
-		reset();
 	}
 
 	private void onImageButtonClicked() {
@@ -124,7 +117,7 @@ class TextAttachmentController extends TextSendController {
 		requireNonNull(imageListener).onAttachImage(intent);
 	}
 
-	void onImageReceived(@Nullable Intent resultData) {
+	public void onImageReceived(@Nullable Intent resultData) {
 		if (resultData == null) return;
 		if (resultData.getData() != null) {
 			imageUris = singletonList(resultData.getData());
@@ -205,7 +198,7 @@ class TextAttachmentController extends TextSendController {
 		} else {
 			sendButton.setVisibility(VISIBLE);
 			// enable/disable buttons right away to allow fast sending
-			sendButton.setEnabled(true);
+			sendButton.setEnabled(enabled);
 			imageButton.setEnabled(false);
 			if (SDK_INT <= 15) {
 				imageButton.setVisibility(INVISIBLE);
@@ -231,6 +224,7 @@ class TextAttachmentController extends TextSendController {
 		showImageButton(true);
 	}
 
+	@Override
 	public Parcelable onSaveInstanceState(@Nullable Parcelable superState) {
 		SavedState state =
 				new SavedState(superState == null ? EMPTY_STATE : superState);
@@ -238,8 +232,9 @@ class TextAttachmentController extends TextSendController {
 		return state;
 	}
 
+	@Override
 	@Nullable
-	public Parcelable onRestoreInstanceState(Parcelable inState) {
+	public Parcelable onRestoreInstanceState(@NonNull Parcelable inState) {
 		SavedState state = (SavedState) inState;
 		imageUris = state.imageUris;
 		onNewUris();
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/view/TextInputController.java b/briar-android/src/main/java/org/briarproject/briar/android/view/TextInputController.java
index 991f37af1f654bca15e50b4facae76427f81c479..4b765c53d3eaa935646e77412de6b8ae41b81416 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/view/TextInputController.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/view/TextInputController.java
@@ -6,7 +6,6 @@ import android.os.IBinder;
 import android.support.annotation.Nullable;
 import android.support.annotation.StringRes;
 import android.support.annotation.UiThread;
-import android.support.design.widget.Snackbar;
 import android.support.v7.widget.AppCompatImageButton;
 import android.text.Editable;
 import android.text.TextWatcher;
@@ -21,9 +20,9 @@ import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
 import org.briarproject.briar.R;
 
 import static android.content.Context.INPUT_METHOD_SERVICE;
-import static android.support.design.widget.Snackbar.LENGTH_SHORT;
 import static android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT;
 import static java.util.Objects.requireNonNull;
+import static org.briarproject.bramble.util.StringUtils.utf8IsTooLong;
 import static org.briarproject.briar.android.view.TextInputView.TextValidityListener;
 
 @UiThread
@@ -35,7 +34,8 @@ class TextInputController implements TextWatcher {
 	private final EmojiPopup emojiPopup;
 	private final EmojiEditText editText;
 
-	private @Nullable TextValidityListener listener;
+	@Nullable
+	private TextValidityListener listener;
 	private int maxLength = Integer.MAX_VALUE;
 	private final boolean emptyTextAllowed;
 	private boolean isEmpty = true;
@@ -66,15 +66,16 @@ class TextInputController implements TextWatcher {
 	@Override
 	public void onTextChanged(CharSequence s, int start, int before,
 			int count) {
-		if (emptyTextAllowed || listener == null) return;
-		if (s.toString().trim().length() == 0) {
+		// Need to start at position 0 to change empty
+		if (start != 0 || emptyTextAllowed || listener == null) return;
+		if (s.length() == 0) {
 			if (!isEmpty) {
 				isEmpty = true;
-				listener.onTextValidityChanged(true);
+				listener.onTextIsEmptyChanged(true);
 			}
 		} else if (isEmpty) {
 			isEmpty = false;
-			listener.onTextValidityChanged(false);
+			listener.onTextIsEmptyChanged(false);
 		}
 	}
 
@@ -86,9 +87,13 @@ class TextInputController implements TextWatcher {
 		this.maxLength = maxLength;
 	}
 
+	boolean isEmpty() {
+		return getText() == null;
+	}
+
 	boolean isTooLong() {
 		return editText.getText() != null &&
-				editText.getText().toString().trim().length() > maxLength;
+				utf8IsTooLong(editText.getText().toString().trim(), maxLength);
 	}
 
 	/**
@@ -98,9 +103,9 @@ class TextInputController implements TextWatcher {
 	@Nullable
 	String getText() {
 		Editable editable = editText.getText();
-		if (editable == null || editable.toString().trim().length() == 0)
-			return null;
-		return editable.toString().trim();
+		String str = editable == null ? null : editable.toString().trim();
+		if (str == null || str.length() == 0) return null;
+		return str;
 	}
 
 	void clearText() {
@@ -123,10 +128,6 @@ class TextInputController implements TextWatcher {
 		this.listener = listener;
 	}
 
-	void showError() {
-		Snackbar.make(editText, R.string.text_too_long, LENGTH_SHORT).show();
-	}
-
 	boolean requestFocus(int direction, Rect previouslyFocusedRect) {
 		return editText.requestFocus(direction, previouslyFocusedRect);
 	}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/view/TextInputView.java b/briar-android/src/main/java/org/briarproject/briar/android/view/TextInputView.java
index f57311d44d421888ed2d700f960b05935461c636..21e4c0950c0de6df7b66f5a484a962672c519e42 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/view/TextInputView.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/view/TextInputView.java
@@ -14,8 +14,6 @@ import android.support.annotation.UiThread;
 import android.support.v7.widget.AppCompatImageButton;
 import android.util.AttributeSet;
 import android.view.LayoutInflater;
-import android.view.View;
-import android.view.WindowManager;
 
 import com.vanniktech.emoji.EmojiEditText;
 import com.vanniktech.emoji.RecentEmoji;
@@ -43,12 +41,9 @@ public class TextInputView extends KeyboardAwareLinearLayout {
 	RecentEmoji recentEmoji;
 
 	TextInputController textInputController;
+	@Nullable
 	TextSendController textSendController;
 	EmojiEditText editText;
-	View sendButton;
-
-	@Nullable
-	TextAttachmentController attachmentController;
 
 	public TextInputView(Context context) {
 		this(context, null);
@@ -87,8 +82,6 @@ public class TextInputView extends KeyboardAwareLinearLayout {
 		String hint = attributes.getString(R.styleable.TextInputView_hint);
 		boolean allowEmptyText = attributes
 				.getBoolean(R.styleable.TextInputView_allowEmptyText, false);
-		boolean supportsAttachments = attributes
-				.getBoolean(R.styleable.TextInputView_supportsAttachments, false);
 		attributes.recycle();
 
 		// set up input controller
@@ -97,69 +90,55 @@ public class TextInputView extends KeyboardAwareLinearLayout {
 		textInputController = new TextInputController(this, emojiToggle,
 				editText, recentEmoji, allowEmptyText);
 		if (hint != null) textInputController.setHint(hint);
-
-		// set up sending controller
-		sendButton = findViewById(R.id.btn_send);
-		if (supportsAttachments) {
-			textSendController = new TextAttachmentController(this, sendButton,
-					textInputController);
-		} else {
-			textSendController = new TextSendController(sendButton,
-					textInputController, allowEmptyText);
-		}
-		textInputController.setTextValidityListener(textSendController);
-
-		// support sending with Ctrl+Enter
-		editText.setOnKeyListener((v, keyCode, event) -> {
-			if (keyCode == KEYCODE_ENTER && event.isCtrlPressed()) {
-				textSendController.onSendButtonClicked();
-				return true;
-			}
-			return false;
-		});
 	}
 
 	@Nullable
 	@Override
 	protected Parcelable onSaveInstanceState() {
 		Parcelable superState = super.onSaveInstanceState();
-		if (attachmentController != null) {
-			superState = attachmentController.onSaveInstanceState(superState);
+		if (textSendController != null) {
+			superState = textSendController.onSaveInstanceState(superState);
 		}
 		return superState;
 	}
 
 	@Override
 	protected void onRestoreInstanceState(Parcelable state) {
-		if (attachmentController != null) {
+		if (textSendController != null) {
 			Parcelable outState =
-					attachmentController.onRestoreInstanceState(state);
+					textSendController.onRestoreInstanceState(state);
 			super.onRestoreInstanceState(outState);
 		} else {
 			super.onRestoreInstanceState(state);
 		}
 	}
 
-	public void setListener(SendListener listener) {
-		textSendController.setSendListener(listener);
-	}
+	/**
+	 * Call this in onCreate() before any other methods of this class.
+	 */
+	public <T extends TextSendController> void setSendController(T controller) {
+		textSendController = controller;
+		textInputController.setTextValidityListener(textSendController);
 
-	public void setAttachImageListener(AttachImageListener imageListener,
-			WindowManager windowManager) {
-		attachmentController = (TextAttachmentController) textSendController;
-		attachmentController.setAttachImageListener(imageListener, windowManager);
+		// support sending with Ctrl+Enter
+		editText.setOnKeyListener((v, keyCode, event) -> {
+			if (keyCode == KEYCODE_ENTER && event.isCtrlPressed()) {
+				textSendController.onSendButtonClicked();
+				return true;
+			}
+			return false;
+		});
 	}
 
-	public void onImageReceived(@Nullable Intent resultData) {
-		if (attachmentController == null) throw new IllegalStateException();
-		attachmentController.onImageReceived(resultData);
+	public TextInputController getTextInputController() {
+		return textInputController;
 	}
 
 	@Override
 	public void setEnabled(boolean enabled) {
 		super.setEnabled(enabled);
 		textInputController.setEnabled(enabled);
-		textSendController.setEnabled(enabled);
+		requireNonNull(textSendController).setEnabled(enabled);
 	}
 
 	@Override
@@ -195,7 +174,7 @@ public class TextInputView extends KeyboardAwareLinearLayout {
 	}
 
 	interface TextValidityListener {
-		void onTextValidityChanged(boolean isEmpty);
+		void onTextIsEmptyChanged(boolean isEmpty);
 	}
 
 	public interface AttachImageListener {
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/view/TextSendController.java b/briar-android/src/main/java/org/briarproject/briar/android/view/TextSendController.java
index 3d0cb30c1f73f81535030f1efac80d185db3baa8..fc04484a7c62eefe2e1974bbd869bf9203d18b94 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/view/TextSendController.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/view/TextSendController.java
@@ -1,62 +1,77 @@
 package org.briarproject.briar.android.view;
 
+import android.os.Parcelable;
+import android.support.annotation.CallSuper;
 import android.support.annotation.Nullable;
 import android.support.annotation.UiThread;
+import android.support.design.widget.Snackbar;
 import android.view.View;
 
 import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.briar.R;
 import org.briarproject.briar.android.view.TextInputView.SendListener;
 import org.briarproject.briar.android.view.TextInputView.TextValidityListener;
 
+import static android.support.design.widget.Snackbar.LENGTH_SHORT;
 import static java.util.Collections.emptyList;
 
 @UiThread
 @NotNullByDefault
-class TextSendController implements TextValidityListener {
+public class TextSendController implements TextValidityListener {
 
 	protected final TextInputController textInput;
 	protected final View sendButton;
-	@Nullable
-	protected SendListener listener;
+	protected final SendListener listener;
 	protected boolean enabled = true;
 
 	private final boolean allowEmptyText;
 	private boolean wasEmpty = true;
 
-	TextSendController(View sendButton, TextInputController textInput,
+	public TextSendController(TextInputView v, SendListener listener,
 			boolean allowEmptyText) {
-		this.sendButton = sendButton;
-		this.sendButton.setOnClickListener(v -> onSendButtonClicked());
+		this.sendButton = v.findViewById(R.id.btn_send);
+		this.sendButton.setOnClickListener(view -> onSendButtonClicked());
 		this.sendButton.setEnabled(allowEmptyText);
-		this.textInput = textInput;
+		this.listener = listener;
+		this.textInput = v.getTextInputController();
 		this.allowEmptyText = allowEmptyText;
 	}
 
 	@Override
-	public void onTextValidityChanged(boolean isEmpty) {
+	public void onTextIsEmptyChanged(boolean isEmpty) {
 		sendButton.setEnabled(enabled && !isEmpty);
 		wasEmpty = isEmpty;
 	}
 
-	public void setEnabled(boolean enabled) {
-		sendButton.setOnClickListener(
-				enabled ? v -> onSendButtonClicked() : null);
-		sendButton.setEnabled(!wasEmpty || allowEmptyText);
-		this.enabled = enabled;
+	@Nullable
+	public Parcelable onSaveInstanceState(@Nullable Parcelable superState) {
+		return superState;
 	}
 
-	void setSendListener(SendListener listener) {
-		this.listener = listener;
+	@Nullable
+	public Parcelable onRestoreInstanceState(Parcelable state) {
+		return state;
+	}
+
+	@CallSuper
+	public void setEnabled(boolean enabled) {
+		sendButton.setEnabled(enabled && (!wasEmpty || allowEmptyText));
+		this.enabled = enabled;
 	}
 
 	void onSendButtonClicked() {
-		if (listener != null) {
-			if (textInput.isTooLong()) {
-				textInput.showError();
-				return;
-			}
+		if (canSend()) {
 			listener.onSendClick(textInput.getText(), emptyList());
 		}
 	}
 
+	protected boolean canSend() {
+		if (textInput.isTooLong()) {
+			Snackbar.make(sendButton, R.string.text_too_long, LENGTH_SHORT)
+					.show();
+			return false;
+		}
+		return enabled && (allowEmptyText || !textInput.isEmpty());
+	}
+
 }
diff --git a/briar-android/src/main/res/layout/activity_conversation.xml b/briar-android/src/main/res/layout/activity_conversation.xml
index 4ae3cedab90112a7cf34ce7c6cb15e996b4bc050..a3afa6b6d70fa03ac0a829bcc2d01b4e21239977 100644
--- a/briar-android/src/main/res/layout/activity_conversation.xml
+++ b/briar-android/src/main/res/layout/activity_conversation.xml
@@ -54,7 +54,6 @@
 		android:id="@+id/text_input_container"
 		android:layout_width="match_parent"
 		android:layout_height="wrap_content"
-		app:hint="@string/message_hint"
-		app:supportsAttachments="true"/>
+		app:hint="@string/message_hint"/>
 
 </LinearLayout>
\ No newline at end of file
diff --git a/briar-android/src/main/res/values/attrs.xml b/briar-android/src/main/res/values/attrs.xml
index a97af7189acd3858f8507554ad5c804a39e36527..eab6a660a1c6de2e56245e1237a107b11ba4bfd7 100644
--- a/briar-android/src/main/res/values/attrs.xml
+++ b/briar-android/src/main/res/values/attrs.xml
@@ -22,7 +22,6 @@
 	<declare-styleable name="TextInputView">
 		<attr name="hint" format="string"/>
 		<attr name="allowEmptyText" format="boolean"/>
-		<attr name="supportsAttachments" format="boolean"/>
 	</declare-styleable>
 
 	<declare-styleable name="LargeTextInputView">