diff --git a/briar-android/build.gradle b/briar-android/build.gradle
index d9e68377505e960730090713fd49e5ccff969076..ea742e84e7d409a16364dfc5a5e698786b8dd08f 100644
--- a/briar-android/build.gradle
+++ b/briar-android/build.gradle
@@ -60,6 +60,7 @@ dependencies {
 	androidTestAnnotationProcessor "com.google.dagger:dagger-compiler:2.0.2"
 	androidTestCompileOnly 'javax.annotation:jsr250-api:1.0'
 	androidTestImplementation 'junit:junit:4.12'
+	implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
 }
 
 def getStdout = { command, defaultValue ->
diff --git a/briar-android/src/main/AndroidManifest.xml b/briar-android/src/main/AndroidManifest.xml
index be1d305ff9e0e0daf9587d61a881096d4248d8da..83630d50c1ad07e899f68b7f9324cc9f44c8e653 100644
--- a/briar-android/src/main/AndroidManifest.xml
+++ b/briar-android/src/main/AndroidManifest.xml
@@ -328,7 +328,7 @@
 		</activity>
 
 		<activity
-			android:name="org.briarproject.briar.android.keyagreement.MailboxExchangeActivity"
+			android:name="org.briarproject.briar.android.keyagreement.mailbox.MailboxKeyAgreementActivity"
 			android:label="@string/mailbox_add"
 			android:parentActivityName="org.briarproject.briar.android.settings.SettingsActivity"
 			android:theme="@style/BriarTheme.NoActionBar">
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java b/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java
index 7b369e53686f4f9be7fbf59ecd4a8758d98c4638..7ab6fe90310aae4f6c55f155d5e34b51e3744a22 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java
@@ -31,7 +31,10 @@ import org.briarproject.briar.android.keyagreement.ContactExchangeErrorFragment;
 import org.briarproject.briar.android.keyagreement.IntroFragment;
 import org.briarproject.briar.android.keyagreement.KeyAgreementActivity;
 import org.briarproject.briar.android.keyagreement.KeyAgreementFragment;
-import org.briarproject.briar.android.keyagreement.MailboxExchangeActivity;
+import org.briarproject.briar.android.keyagreement.mailbox.ExchangeFragment;
+import org.briarproject.briar.android.keyagreement.mailbox.MailboxKeyAgreementActivity;
+import org.briarproject.briar.android.keyagreement.mailbox.ScanQrCodeFragment;
+import org.briarproject.briar.android.keyagreement.mailbox.ShowQrCodeFragment;
 import org.briarproject.briar.android.login.AuthorNameFragment;
 import org.briarproject.briar.android.login.ChangePasswordActivity;
 import org.briarproject.briar.android.login.DozeFragment;
@@ -108,8 +111,6 @@ public interface ActivityComponent {
 
 	void inject(ContactExchangeActivity activity);
 
-	void inject(MailboxExchangeActivity activity);
-
 	void inject(KeyAgreementActivity activity);
 
 	void inject(ConversationActivity activity);
@@ -220,4 +221,12 @@ public interface ActivityComponent {
 	void inject(ContactExchangeErrorFragment fragment);
 
 	void inject(MailboxFragment mailboxFragment);
+
+	void inject(MailboxKeyAgreementActivity mailboxKeyAgreementActivity);
+
+	void inject(ScanQrCodeFragment scanQrCodeFragment);
+
+	void inject(ShowQrCodeFragment showQrCodeFragment);
+
+	void inject(ExchangeFragment exchangeFragment);
 }
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityModule.java b/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityModule.java
index b21d072321464b72d3e20d4123f46b12235651d3..775cd5e6440e83e5be1452ac56af010e4b2796c0 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityModule.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityModule.java
@@ -6,6 +6,8 @@ import org.briarproject.briar.android.controller.BriarController;
 import org.briarproject.briar.android.controller.BriarControllerImpl;
 import org.briarproject.briar.android.controller.DbController;
 import org.briarproject.briar.android.controller.DbControllerImpl;
+import org.briarproject.briar.android.keyagreement.mailbox.KeyAgreementController;
+import org.briarproject.briar.android.keyagreement.mailbox.KeyAgreementControllerImpl;
 import org.briarproject.briar.android.login.PasswordController;
 import org.briarproject.briar.android.login.PasswordControllerImpl;
 import org.briarproject.briar.android.login.SetupController;
@@ -46,6 +48,13 @@ public class ActivityModule {
 		return setupController;
 	}
 
+	@ActivityScope
+	@Provides
+	KeyAgreementController provideKeyAgreementController(
+			KeyAgreementControllerImpl keyAgreementController) {
+		return keyAgreementController;
+	}
+
 	@ActivityScope
 	@Provides
 	PasswordController providePasswordController(
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/CameraException.java b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/CameraException.java
index 0bff14ed7c0e6637b1bf14d68a85e23a21a3d016..cce6bf20731f91deaf06e22240abe2bf9e82ca19 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/CameraException.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/CameraException.java
@@ -2,7 +2,7 @@ package org.briarproject.briar.android.keyagreement;
 
 import java.io.IOException;
 
-class CameraException extends IOException {
+public class CameraException extends IOException {
 
 	CameraException(String message) {
 		super(message);
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/MailboxExchangeActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/MailboxExchangeActivity.java
deleted file mode 100644
index 638c11017de04583f5c29c4453517afc62741c40..0000000000000000000000000000000000000000
--- a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/MailboxExchangeActivity.java
+++ /dev/null
@@ -1,132 +0,0 @@
-package org.briarproject.briar.android.keyagreement;
-
-import android.os.Bundle;
-import android.support.annotation.UiThread;
-import android.widget.Toast;
-
-import org.briarproject.bramble.api.contact.ContactExchangeListener;
-import org.briarproject.bramble.api.contact.ContactExchangeTask;
-import org.briarproject.bramble.api.db.DbException;
-import org.briarproject.bramble.api.identity.Author;
-import org.briarproject.bramble.api.identity.IdentityManager;
-import org.briarproject.bramble.api.identity.LocalAuthor;
-import org.briarproject.bramble.api.keyagreement.KeyAgreementResult;
-import org.briarproject.briar.R;
-import org.briarproject.briar.android.activity.ActivityComponent;
-
-import java.util.logging.Logger;
-
-import javax.annotation.Nullable;
-import javax.inject.Inject;
-
-import static android.widget.Toast.LENGTH_LONG;
-import static java.util.logging.Level.WARNING;
-import static org.briarproject.bramble.api.contact.ContactType.MAILBOX_OWNER;
-import static org.briarproject.bramble.api.contact.ContactType.PRIVATE_MAILBOX;
-import static org.briarproject.bramble.util.LogUtils.logException;
-
-public class MailboxExchangeActivity extends KeyAgreementActivity implements
-		ContactExchangeListener {
-
-	private static final Logger LOG =
-			Logger.getLogger(MailboxExchangeActivity.class.getName());
-
-	// Fields that are accessed from background threads must be volatile
-	@Inject
-	volatile ContactExchangeTask contactExchangeTask;
-	@Inject
-	volatile IdentityManager identityManager;
-
-	@Override
-	public void injectActivity(ActivityComponent component) {
-		component.inject(this);
-	}
-
-	@Override
-	public void onCreate(@Nullable Bundle state) {
-		super.onCreate(state);
-		getSupportActionBar().setTitle(R.string.mailbox_add);
-	}
-
-	protected void startContactExchange(KeyAgreementResult result) {
-		runOnDbThread(() -> {
-			LocalAuthor localAuthor;
-			// Load the local pseudonym
-			try {
-				localAuthor = identityManager.getLocalAuthor();
-			} catch (DbException e) {
-				logException(LOG, WARNING, e);
-				contactExchangeFailed();
-				return;
-			}
-
-			// Exchange contact details
-			contactExchangeTask.startExchange(MailboxExchangeActivity.this,
-					localAuthor, result.getMasterKey(),
-					result.getConnection(), result.getTransportId(),
-					result.wasAlice(), MAILBOX_OWNER, PRIVATE_MAILBOX);
-		});
-	}
-
-	@Override
-	public void contactExchangeSucceeded(Author remoteAuthor) {
-		runOnUiThreadUnlessDestroyed(() -> {
-			String contactName = remoteAuthor.getName();
-			String format = getString(R.string.contact_added_toast);
-			String text = String.format(format, contactName);
-			Toast.makeText(MailboxExchangeActivity.this, text, LENGTH_LONG)
-					.show();
-			supportFinishAfterTransition();
-		});
-	}
-
-	@Override
-	public void duplicateContact(Author remoteAuthor) {
-		runOnUiThreadUnlessDestroyed(() -> {
-			String contactName = remoteAuthor.getName();
-			String format = getString(R.string.contact_already_exists);
-			String text = String.format(format, contactName);
-			Toast.makeText(MailboxExchangeActivity.this, text, LENGTH_LONG)
-					.show();
-			finish();
-		});
-	}
-
-	@Override
-	public void contactExchangeFailed() {
-		runOnUiThreadUnlessDestroyed(() -> {
-			showErrorFragment(R.string.connection_error_explanation);
-		});
-	}
-
-	@UiThread
-	@Override
-	public void keyAgreementFailed() {
-		showErrorFragment(R.string.connection_error_explanation);
-	}
-
-	@UiThread
-	@Override
-	public String keyAgreementWaiting() {
-		return getString(R.string.waiting_for_contact_to_scan);
-	}
-
-	@UiThread
-	@Override
-	public String keyAgreementStarted() {
-		return getString(R.string.authenticating_with_device);
-	}
-
-	@UiThread
-	@Override
-	public void keyAgreementAborted(boolean remoteAborted) {
-		showErrorFragment(R.string.connection_error_explanation);
-	}
-
-	@UiThread
-	@Override
-	public String keyAgreementFinished(KeyAgreementResult result) {
-		startContactExchange(result);
-		return getString(R.string.exchanging_contact_details);
-	}
-}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/QrCodeDecoder.java b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/QrCodeDecoder.java
index 7b018be03a96dab6abbb9836310071c5858685ff..efd8f62a80c8f02743fcc21158f8b055edce95e5 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/QrCodeDecoder.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/QrCodeDecoder.java
@@ -29,7 +29,7 @@ import static java.util.logging.Level.WARNING;
 @SuppressWarnings("deprecation")
 @MethodsNotNullByDefault
 @ParametersNotNullByDefault
-class QrCodeDecoder implements PreviewConsumer, PreviewCallback {
+public class QrCodeDecoder implements PreviewConsumer, PreviewCallback {
 
 	private static final Logger LOG =
 			Logger.getLogger(QrCodeDecoder.class.getName());
@@ -40,7 +40,7 @@ class QrCodeDecoder implements PreviewConsumer, PreviewCallback {
 	private Camera camera = null;
 	private int cameraIndex = 0;
 
-	QrCodeDecoder(ResultCallback callback) {
+	public QrCodeDecoder(ResultCallback callback) {
 		this.callback = callback;
 	}
 
@@ -141,6 +141,7 @@ class QrCodeDecoder implements PreviewConsumer, PreviewCallback {
 	}
 
 	@NotNullByDefault
+	public
 	interface ResultCallback {
 
 		void handleResult(Result result);
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/QrCodeUtils.java b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/QrCodeUtils.java
index 617f84b996ef44fd7edbd68be76594970b261ce0..d330436da65ff312cef80e81f5878387ca6129b9 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/QrCodeUtils.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/QrCodeUtils.java
@@ -21,13 +21,14 @@ import static java.util.logging.Level.WARNING;
 import static org.briarproject.bramble.util.LogUtils.logException;
 
 @NotNullByDefault
+public
 class QrCodeUtils {
 
 	private static final Logger LOG =
 			Logger.getLogger(QrCodeUtils.class.getName());
 
 	@Nullable
-	static Bitmap createQrCode(DisplayMetrics dm, String input) {
+	public static Bitmap createQrCode(DisplayMetrics dm, String input) {
 		int smallestDimen = Math.min(dm.widthPixels, dm.heightPixels);
 		try {
 			// Generate QR code
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/ExchangeFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/ExchangeFragment.java
new file mode 100644
index 0000000000000000000000000000000000000000..3196503954767b669ca4a8e9ecb91ac677362c2f
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/ExchangeFragment.java
@@ -0,0 +1,105 @@
+package org.briarproject.briar.android.keyagreement.mailbox;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import org.briarproject.bramble.api.event.Event;
+import org.briarproject.bramble.api.keyagreement.event.KeyAgreementFinishedEvent;
+import org.briarproject.bramble.api.keyagreement.event.KeyAgreementStartedEvent;
+import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
+import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
+import org.briarproject.briar.R;
+import org.briarproject.briar.android.activity.ActivityComponent;
+import org.briarproject.briar.android.fragment.BaseEventFragment;
+
+import java.util.logging.Logger;
+
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+
+import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_NOSENSOR;
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+public class ExchangeFragment extends BaseEventFragment {
+	static final String TAG = ExchangeFragment.class.getName();
+
+	private static final Logger LOG = Logger.getLogger(TAG);
+	@Inject
+	KeyAgreementController keyAgreementController;
+
+	private TextView statusView;
+	private KeyAgreementEventListener listener;
+
+	public static ExchangeFragment newInstance() {
+		Bundle args = new Bundle();
+		ExchangeFragment fragment = new ExchangeFragment();
+		fragment.setArguments(args);
+		return fragment;
+	}
+
+	@Override
+	public void onAttach(Context context) {
+		super.onAttach(context);
+		listener = (KeyAgreementEventListener) context;
+	}
+
+	@Override
+	public void injectFragment(ActivityComponent component) {
+		component.inject(this);
+	}
+
+	@Override
+	public String getUniqueTag() {
+		return TAG;
+	}
+
+	@Nullable
+	@Override
+	public View onCreateView(LayoutInflater inflater,
+			@Nullable ViewGroup container,
+			@Nullable Bundle savedInstanceState) {
+		return inflater
+				.inflate(R.layout.fragment_keyagreement_mailbox_exchange,
+						container,
+						false);
+	}
+
+	@Override
+	public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
+		super.onViewCreated(view, savedInstanceState);
+		statusView = view.findViewById(R.id.nextButton);
+		if (savedInstanceState == null)
+			keyAgreementController.startKeyAgreement();
+	}
+
+	@Override
+	public void onActivityCreated(@Nullable Bundle savedInstanceState) {
+		super.onActivityCreated(savedInstanceState);
+		getActivity().setRequestedOrientation(SCREEN_ORIENTATION_NOSENSOR);
+	}
+
+	@Override
+	protected void finish() {
+		getActivity().getSupportFragmentManager().popBackStack();
+	}
+
+	@Override
+	public void eventOccurred(Event e) {
+		if (e instanceof KeyAgreementStartedEvent) {
+			runOnUiThreadUnlessDestroyed(() -> {
+				statusView.setText(R.string.authenticating_with_device);
+			});
+		} else if (e instanceof KeyAgreementFinishedEvent) {
+			runOnUiThreadUnlessDestroyed(() -> {
+				keyAgreementController.startContactExchange(
+						((KeyAgreementFinishedEvent) e).getResult(), listener);
+				statusView.setText(R.string.exchanging_contact_details);
+			});
+		}
+	}
+}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/KeyAgreementController.java b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/KeyAgreementController.java
new file mode 100644
index 0000000000000000000000000000000000000000..a66ed61ebda9ffe6e3af5fab15712f88bcf72b96
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/KeyAgreementController.java
@@ -0,0 +1,38 @@
+package org.briarproject.briar.android.keyagreement.mailbox;
+
+import android.support.annotation.UiThread;
+
+import org.briarproject.bramble.api.contact.ContactExchangeListener;
+import org.briarproject.bramble.api.keyagreement.KeyAgreementResult;
+import org.briarproject.bramble.api.keyagreement.Payload;
+import org.briarproject.briar.android.controller.handler.UiResultHandler;
+
+public interface KeyAgreementController {
+	/**
+	 * Call this when your lifecycle starts,
+	 * so the listener will be called when information changes.
+	 *
+	 * @param resuming
+	 */
+	@UiThread
+	void onStart(boolean resuming);
+
+	/**
+	 * Call this when your lifecycle stops,
+	 * so that the controller knows it can stops listening to events.
+	 */
+	@UiThread
+	void onStop();
+
+	void getQrCode(UiResultHandler<Payload> resultHandler);
+
+	void setRemoteQrCode(Payload remotePayload);
+
+	void startKeyAgreement();
+
+	void startContactExchange(KeyAgreementResult result,
+			ContactExchangeListener listener);
+
+	void reset();
+
+}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/KeyAgreementControllerImpl.java b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/KeyAgreementControllerImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..d6c2acc95df05c26422d36330c148c5009bcf36f
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/KeyAgreementControllerImpl.java
@@ -0,0 +1,156 @@
+package org.briarproject.briar.android.keyagreement.mailbox;
+
+import android.support.annotation.UiThread;
+
+import org.briarproject.bramble.api.contact.ContactExchangeListener;
+import org.briarproject.bramble.api.contact.ContactExchangeTask;
+import org.briarproject.bramble.api.db.DatabaseExecutor;
+import org.briarproject.bramble.api.db.DbException;
+import org.briarproject.bramble.api.event.Event;
+import org.briarproject.bramble.api.event.EventBus;
+import org.briarproject.bramble.api.event.EventListener;
+import org.briarproject.bramble.api.identity.IdentityManager;
+import org.briarproject.bramble.api.identity.LocalAuthor;
+import org.briarproject.bramble.api.keyagreement.KeyAgreementResult;
+import org.briarproject.bramble.api.keyagreement.KeyAgreementTask;
+import org.briarproject.bramble.api.keyagreement.Payload;
+import org.briarproject.bramble.api.keyagreement.event.KeyAgreementListeningEvent;
+import org.briarproject.bramble.api.lifecycle.IoExecutor;
+import org.briarproject.briar.android.controller.handler.UiResultHandler;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
+import java.util.logging.Logger;
+
+import javax.inject.Inject;
+import javax.inject.Provider;
+
+import static java.util.logging.Level.WARNING;
+import static org.briarproject.bramble.api.contact.ContactType.MAILBOX_OWNER;
+import static org.briarproject.bramble.api.contact.ContactType.PRIVATE_MAILBOX;
+import static org.briarproject.bramble.util.LogUtils.logException;
+
+public class KeyAgreementControllerImpl implements KeyAgreementController,
+		EventListener {
+
+	private static final Logger LOG =
+			Logger.getLogger(KeyAgreementControllerImpl.class.getName());
+
+	private final Executor dbExecutor, ioExecutor;
+	private final EventBus eventBus;
+	private final Provider<KeyAgreementTask> keyAgreementTaskProvider;
+	private volatile KeyAgreementTask task;
+	private volatile Payload localPayload, remotePayload;
+	private volatile CountDownLatch waitingForTask;
+	private IdentityManager identityManager;
+	private final ContactExchangeTask contactExchangeTask;
+
+	@Inject
+	KeyAgreementControllerImpl(@DatabaseExecutor Executor dbExecutor,
+			@IoExecutor Executor ioExecutor,
+			EventBus eventBus,
+			Provider<KeyAgreementTask> keyAgreementTaskProvider,
+			IdentityManager identityManager,
+			ContactExchangeTask contactExchangeTask) {
+		this.dbExecutor = dbExecutor;
+		this.ioExecutor = ioExecutor;
+		this.eventBus = eventBus;
+		this.keyAgreementTaskProvider = keyAgreementTaskProvider;
+		this.identityManager = identityManager;
+		this.contactExchangeTask = contactExchangeTask;
+	}
+
+	@Override
+	public void onStart(boolean resuming) {
+		eventBus.addListener(this);
+		if (!resuming) startListening();
+	}
+
+	@Override
+	public void onStop() {
+		eventBus.removeListener(this);
+		stopListening();
+	}
+
+	@Override
+	public void reset() {
+		localPayload = null;
+		startListening();
+	}
+
+	@UiThread
+	private void startListening() {
+		waitingForTask = new CountDownLatch(1);
+		KeyAgreementTask oldTask = task;
+		KeyAgreementTask newTask = keyAgreementTaskProvider.get();
+		task = newTask;
+		ioExecutor.execute(() -> {
+			if (oldTask != null) oldTask.stopListening();
+			newTask.listen();
+		});
+	}
+
+	@UiThread
+	private void stopListening() {
+		KeyAgreementTask oldTask = task;
+		ioExecutor.execute(() -> {
+			if (oldTask != null) oldTask.stopListening();
+		});
+	}
+
+	@Override
+	public synchronized void getQrCode(UiResultHandler<Payload> resultHandler) {
+		ioExecutor.execute(() -> {
+			try {
+				waitingForTask.await();
+			} catch (InterruptedException e) {
+				//TODO: Log
+				return;
+			}
+			resultHandler.onResultUi(localPayload);
+		});
+	}
+
+	@Override
+	public void setRemoteQrCode(Payload remotePayload) {
+		this.remotePayload = remotePayload;
+	}
+
+	@Override
+	public void startKeyAgreement() {
+		if (remotePayload == null) throw new IllegalStateException();
+		task.connectAndRunProtocol(remotePayload);
+	}
+
+	@Override
+	public void startContactExchange(KeyAgreementResult result,
+			ContactExchangeListener listener) {
+		dbExecutor.execute(() -> {
+			LocalAuthor localAuthor;
+			// Load the local pseudonym
+			try {
+				localAuthor = identityManager.getLocalAuthor();
+			} catch (DbException e) {
+				logException(LOG, WARNING, e);
+				listener.contactExchangeFailed();
+				return;
+			}
+
+			// Exchange contact details
+			contactExchangeTask.startExchange(listener,
+					localAuthor, result.getMasterKey(),
+					result.getConnection(), result.getTransportId(),
+					result.wasAlice(), MAILBOX_OWNER, PRIVATE_MAILBOX);
+		});
+	}
+
+	@Override
+	public void eventOccurred(Event e) {
+		if (e instanceof KeyAgreementListeningEvent) {
+			localPayload =
+					((KeyAgreementListeningEvent) e).getLocalPayload();
+			waitingForTask.countDown();
+		}
+
+	}
+}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/KeyAgreementEventListener.java b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/KeyAgreementEventListener.java
new file mode 100644
index 0000000000000000000000000000000000000000..c9bde3a14a28754c2fd0339d600729b52e900094
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/KeyAgreementEventListener.java
@@ -0,0 +1,12 @@
+package org.briarproject.briar.android.keyagreement.mailbox;
+
+import org.briarproject.bramble.api.contact.ContactExchangeListener;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+
+@NotNullByDefault
+interface KeyAgreementEventListener extends ContactExchangeListener {
+
+	void showCameraFragment();
+
+	void showExchangeFragment();
+}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/MailboxIntroFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/MailboxIntroFragment.java
new file mode 100644
index 0000000000000000000000000000000000000000..e47d9623e774e3f9b37f00865193b4e0550f6ebe
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/MailboxIntroFragment.java
@@ -0,0 +1,77 @@
+package org.briarproject.briar.android.keyagreement.mailbox;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ScrollView;
+
+import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
+import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
+import org.briarproject.briar.R;
+import org.briarproject.briar.android.activity.ActivityComponent;
+import org.briarproject.briar.android.fragment.BaseFragment;
+
+import javax.annotation.Nullable;
+
+import static android.view.View.FOCUS_DOWN;
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+public class MailboxIntroFragment extends BaseFragment {
+
+	interface IntroScreenSeenListener {
+		void showNextScreen();
+	}
+
+	public static final String TAG = MailboxIntroFragment.class.getName();
+
+	private IntroScreenSeenListener screenSeenListener;
+	private ScrollView scrollView;
+
+	public static MailboxIntroFragment newInstance() {
+
+		Bundle args = new Bundle();
+
+		MailboxIntroFragment fragment = new MailboxIntroFragment();
+		fragment.setArguments(args);
+		return fragment;
+	}
+
+	public void injectFragment(ActivityComponent component) {
+//		component.inject(this);
+	}
+
+	@Override
+	public void onAttach(Context context) {
+		super.onAttach(context);
+		screenSeenListener = (IntroScreenSeenListener) context;
+	}
+
+	@Override
+	public String getUniqueTag() {
+		return TAG;
+	}
+
+	@Nullable
+	@Override
+	public View onCreateView(LayoutInflater inflater,
+			@Nullable ViewGroup container,
+			@Nullable Bundle savedInstanceState) {
+
+		View v = inflater.inflate(R.layout.fragment_keyagreement_id, container,
+				false);
+		scrollView = v.findViewById(R.id.scrollView);
+		View button = v.findViewById(R.id.continueButton);
+		button.setOnClickListener(view -> screenSeenListener.showNextScreen());
+		return v;
+	}
+
+	@Override
+	public void onStart() {
+		super.onStart();
+		scrollView.post(() -> scrollView.fullScroll(FOCUS_DOWN));
+	}
+
+}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/MailboxKeyAgreementActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/MailboxKeyAgreementActivity.java
new file mode 100644
index 0000000000000000000000000000000000000000..8a4f013d787bedfed0aa4eb5a178383613c51d6e
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/MailboxKeyAgreementActivity.java
@@ -0,0 +1,366 @@
+package org.briarproject.briar.android.keyagreement.mailbox;
+
+import android.bluetooth.BluetoothAdapter;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.DialogInterface.OnClickListener;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.support.annotation.StringRes;
+import android.support.annotation.UiThread;
+import android.support.v4.app.ActivityCompat;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.content.ContextCompat;
+import android.support.v7.app.AlertDialog.Builder;
+import android.support.v7.widget.Toolbar;
+import android.view.MenuItem;
+import android.widget.Toast;
+
+import org.briarproject.bramble.api.contact.ContactExchangeListener;
+import org.briarproject.bramble.api.event.Event;
+import org.briarproject.bramble.api.event.EventBus;
+import org.briarproject.bramble.api.event.EventListener;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.keyagreement.event.KeyAgreementAbortedEvent;
+import org.briarproject.bramble.api.keyagreement.event.KeyAgreementFailedEvent;
+import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
+import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
+import org.briarproject.bramble.api.plugin.event.BluetoothEnabledEvent;
+import org.briarproject.briar.R;
+import org.briarproject.briar.android.activity.ActivityComponent;
+import org.briarproject.briar.android.activity.BriarActivity;
+import org.briarproject.briar.android.fragment.BaseFragment;
+import org.briarproject.briar.android.keyagreement.ContactExchangeErrorFragment;
+import org.briarproject.briar.android.util.UiUtils;
+
+import java.util.logging.Logger;
+
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+
+import static android.Manifest.permission.CAMERA;
+import static android.bluetooth.BluetoothAdapter.ACTION_REQUEST_ENABLE;
+import static android.bluetooth.BluetoothAdapter.ACTION_STATE_CHANGED;
+import static android.bluetooth.BluetoothAdapter.EXTRA_STATE;
+import static android.bluetooth.BluetoothAdapter.STATE_ON;
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
+import static android.os.Build.VERSION.SDK_INT;
+import static android.widget.Toast.LENGTH_LONG;
+import static org.briarproject.briar.android.activity.RequestCodes.REQUEST_ENABLE_BLUETOOTH;
+import static org.briarproject.briar.android.activity.RequestCodes.REQUEST_PERMISSION_CAMERA;
+import static org.briarproject.briar.android.fragment.BaseFragment.BaseFragmentListener;
+import static org.briarproject.briar.android.keyagreement.mailbox.MailboxIntroFragment.IntroScreenSeenListener;
+import static org.briarproject.briar.android.keyagreement.mailbox.MailboxIntroFragment.newInstance;
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+public class MailboxKeyAgreementActivity extends BriarActivity implements
+		BaseFragmentListener, IntroScreenSeenListener, EventListener,
+		KeyAgreementEventListener, ContactExchangeListener {
+
+	private static final Logger LOG =
+			Logger.getLogger(MailboxKeyAgreementActivity.class.getName());
+	@Inject
+	EventBus eventBus;
+	@Inject
+	volatile KeyAgreementController keyAgreementController;
+
+	private boolean isResumed = false, enableWasRequested = false;
+	private boolean continueClicked, gotCameraPermission;
+	private BluetoothState bluetoothState = BluetoothState.UNKNOWN;
+	private BroadcastReceiver bluetoothReceiver = null;
+
+
+	@Override
+	public void injectActivity(ActivityComponent component) {
+		component.inject(this);
+	}
+
+	@SuppressWarnings("ConstantConditions")
+	@Override
+	public void onCreate(@Nullable Bundle state) {
+		super.onCreate(state);
+		keyAgreementController.onStart(true);
+		setContentView(R.layout.activity_fragment_container_toolbar);
+		// Disable screen timeout
+		findViewById(R.id.fragmentContainer).setKeepScreenOn(true);
+		Toolbar toolbar = findViewById(R.id.toolbar);
+		setSupportActionBar(toolbar);
+		getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+		if (state == null) {
+			showInitialFragment(newInstance());
+		}
+		IntentFilter filter = new IntentFilter(ACTION_STATE_CHANGED);
+		bluetoothReceiver = new BluetoothStateReceiver();
+		registerReceiver(bluetoothReceiver, filter);
+	}
+
+	@Override
+	public void onDestroy() {
+		super.onDestroy();
+		if (bluetoothReceiver != null) unregisterReceiver(bluetoothReceiver);
+	}
+
+	@Override
+	public boolean onOptionsItemSelected(MenuItem item) {
+		switch (item.getItemId()) {
+			case android.R.id.home:
+				onBackPressed();
+				return true;
+			default:
+				return super.onOptionsItemSelected(item);
+		}
+	}
+
+	@Override
+	protected void onPostResume() {
+		super.onPostResume();
+		isResumed = true;
+		// Workaround for
+		// https://code.google.com/p/android/issues/detail?id=190966
+		if (canShowQrCodeFragment()) showCameraFragment();
+	}
+
+	private boolean canShowQrCodeFragment() {
+		return isResumed && continueClicked
+				&& (SDK_INT < 23 || gotCameraPermission)
+				&& bluetoothState != BluetoothState.UNKNOWN
+				&& bluetoothState != BluetoothState.WAITING;
+	}
+
+	@Override
+	public void onStart() {
+		super.onStart();
+		eventBus.addListener(this);
+	}
+
+	@Override
+	protected void onStop() {
+		super.onStop();
+		eventBus.removeListener(this);
+	}
+
+	@Override
+	protected void onPause() {
+		super.onPause();
+		isResumed = false;
+	}
+
+	@Override
+	public void showNextScreen() {
+		continueClicked = true;
+		if (checkPermissions()) {
+			if (shouldRequestEnableBluetooth()) requestEnableBluetooth();
+			else if (canShowQrCodeFragment()) showQrCodeFragment();
+		}
+	}
+
+	private boolean shouldRequestEnableBluetooth() {
+		return bluetoothState == BluetoothState.UNKNOWN
+				|| bluetoothState == BluetoothState.REFUSED;
+	}
+
+	private void requestEnableBluetooth() {
+		BluetoothAdapter bt = BluetoothAdapter.getDefaultAdapter();
+		if (bt == null) {
+			setBluetoothState(BluetoothState.NO_ADAPTER);
+		} else if (bt.isEnabled()) {
+			setBluetoothState(BluetoothState.ENABLED);
+		} else {
+			enableWasRequested = true;
+			setBluetoothState(BluetoothState.WAITING);
+			Intent i = new Intent(ACTION_REQUEST_ENABLE);
+			startActivityForResult(i, REQUEST_ENABLE_BLUETOOTH);
+		}
+	}
+
+	private void setBluetoothState(BluetoothState bluetoothState) {
+		LOG.info("Setting Bluetooth state to " + bluetoothState);
+		this.bluetoothState = bluetoothState;
+		if (enableWasRequested && bluetoothState == BluetoothState.ENABLED) {
+			eventBus.broadcast(new BluetoothEnabledEvent());
+			enableWasRequested = false;
+		}
+		if (canShowQrCodeFragment()) showCameraFragment();
+	}
+
+	@Override
+	public void onActivityResult(int request, int result, Intent data) {
+		// If the request was granted we'll catch the state change event
+		if (request == REQUEST_ENABLE_BLUETOOTH && result == RESULT_CANCELED)
+			setBluetoothState(BluetoothState.REFUSED);
+	}
+
+	public void showCameraFragment() {
+		// FIXME #824
+		FragmentManager fm = getSupportFragmentManager();
+		if (fm.findFragmentByTag(ScanQrCodeFragment.TAG) == null) {
+			BaseFragment f = ScanQrCodeFragment.newInstance();
+			fm.beginTransaction()
+					.replace(R.id.fragmentContainer, f, f.getUniqueTag())
+					.addToBackStack(f.getUniqueTag())
+					.commit();
+		}
+	}
+
+	public void showExchangeFragment() {
+		// FIXME #824
+		FragmentManager fm = getSupportFragmentManager();
+		if (fm.findFragmentByTag(ExchangeFragment.TAG) == null) {
+			BaseFragment f = ExchangeFragment.newInstance();
+			fm.beginTransaction()
+					.replace(R.id.fragmentContainer, f, f.getUniqueTag())
+					.addToBackStack(f.getUniqueTag())
+					.commit();
+		}
+	}
+
+	public void showQrCodeFragment() {
+		continueClicked = false;
+		// FIXME #824
+		FragmentManager fm = getSupportFragmentManager();
+		if (fm.findFragmentByTag(ShowQrCodeFragment.TAG) == null) {
+			BaseFragment f = ShowQrCodeFragment.newInstance();
+			fm.beginTransaction()
+					.replace(R.id.fragmentContainer, f, f.getUniqueTag())
+					.addToBackStack(f.getUniqueTag())
+					.commit();
+		}
+	}
+
+	protected void showErrorFragment(@StringRes int errorResId) {
+		String errorMsg = getString(errorResId);
+		BaseFragment f = ContactExchangeErrorFragment.newInstance(errorMsg);
+		showNextFragment(f);
+	}
+
+	private boolean checkPermissions() {
+		if (ContextCompat.checkSelfPermission(this, CAMERA) !=
+				PERMISSION_GRANTED) {
+			// Should we show an explanation?
+			if (ActivityCompat.shouldShowRequestPermissionRationale(this,
+					CAMERA)) {
+				OnClickListener continueListener =
+						(dialog, which) -> requestPermission();
+				Builder builder = new Builder(this, R.style.BriarDialogTheme);
+				builder.setTitle(R.string.permission_camera_title);
+				builder.setMessage(R.string.permission_camera_request_body);
+				builder.setNeutralButton(R.string.continue_button,
+						continueListener);
+				builder.show();
+			} else {
+				requestPermission();
+			}
+			gotCameraPermission = false;
+			return false;
+		} else {
+			gotCameraPermission = true;
+			return true;
+		}
+	}
+
+	private void requestPermission() {
+		ActivityCompat.requestPermissions(this, new String[] {CAMERA},
+				REQUEST_PERMISSION_CAMERA);
+	}
+
+	@Override
+	@UiThread
+	public void onRequestPermissionsResult(int requestCode,
+			String permissions[], int[] grantResults) {
+		if (requestCode == REQUEST_PERMISSION_CAMERA) {
+			// If request is cancelled, the result arrays are empty.
+			if (grantResults.length > 0 &&
+					grantResults[0] == PERMISSION_GRANTED) {
+				gotCameraPermission = true;
+				showNextScreen();
+			} else {
+				if (!ActivityCompat.shouldShowRequestPermissionRationale(this,
+						CAMERA)) {
+					// The user has permanently denied the request
+					OnClickListener cancelListener =
+							(dialog, which) -> supportFinishAfterTransition();
+					Builder builder =
+							new Builder(this, R.style.BriarDialogTheme);
+					builder.setTitle(R.string.permission_camera_title);
+					builder.setMessage(R.string.permission_camera_denied_body);
+					builder.setPositiveButton(R.string.ok,
+							UiUtils.getGoToSettingsListener(this));
+					builder.setNegativeButton(R.string.cancel, cancelListener);
+					builder.show();
+				} else {
+					Toast.makeText(this,
+							R.string.permission_camera_denied_toast,
+							LENGTH_LONG).show();
+					supportFinishAfterTransition();
+				}
+			}
+		}
+	}
+
+	@Override
+	public void contactExchangeSucceeded(Author remoteAuthor) {
+		runOnUiThreadUnlessDestroyed(() -> {
+			String contactName = remoteAuthor.getName();
+			String format = getString(R.string.contact_added_toast);
+			String text = String.format(format, contactName);
+			Toast.makeText(MailboxKeyAgreementActivity.this, text, LENGTH_LONG)
+					.show();
+			supportFinishAfterTransition();
+		});
+	}
+
+	@Override
+	public void duplicateContact(Author remoteAuthor) {
+		runOnUiThreadUnlessDestroyed(() -> {
+			String contactName = remoteAuthor.getName();
+			String format = getString(R.string.contact_already_exists);
+			String text = String.format(format, contactName);
+			Toast.makeText(MailboxKeyAgreementActivity.this, text, LENGTH_LONG)
+					.show();
+			finish();
+		});
+	}
+
+	@Override
+	public void finish() {
+		keyAgreementController.onStop();
+	}
+
+	@Override
+	public void contactExchangeFailed() {
+		showErrorExplanationFragmentAndReset();
+	}
+
+	@Override
+	public void eventOccurred(Event e) {
+		if (e instanceof KeyAgreementFailedEvent) {
+			showErrorExplanationFragmentAndReset();
+		} else if (e instanceof KeyAgreementAbortedEvent) {
+			showErrorExplanationFragmentAndReset();
+		}
+	}
+
+	private void showErrorExplanationFragmentAndReset() {
+		runOnUiThreadUnlessDestroyed(
+				() -> {
+					keyAgreementController.reset();
+					showErrorFragment(R.string.connection_error_explanation);
+				});
+	}
+
+	private enum BluetoothState {
+		UNKNOWN, NO_ADAPTER, WAITING, REFUSED, ENABLED
+	}
+
+	private class BluetoothStateReceiver extends BroadcastReceiver {
+
+		@Override
+		public void onReceive(Context context, Intent intent) {
+			int state = intent.getIntExtra(EXTRA_STATE, 0);
+			if (state == STATE_ON) setBluetoothState(BluetoothState.ENABLED);
+			else setBluetoothState(BluetoothState.UNKNOWN);
+		}
+	}
+}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/ScanQrCodeFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/ScanQrCodeFragment.java
new file mode 100644
index 0000000000000000000000000000000000000000..cf950d1674098bab54beee3c2429044b31345af5
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/ScanQrCodeFragment.java
@@ -0,0 +1,163 @@
+package org.briarproject.briar.android.keyagreement.mailbox;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.support.annotation.UiThread;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Toast;
+
+import com.google.zxing.Result;
+
+import org.briarproject.bramble.api.UnsupportedVersionException;
+import org.briarproject.bramble.api.keyagreement.Payload;
+import org.briarproject.bramble.api.keyagreement.PayloadParser;
+import org.briarproject.briar.R;
+import org.briarproject.briar.android.activity.ActivityComponent;
+import org.briarproject.briar.android.fragment.BaseFragment;
+import org.briarproject.briar.android.keyagreement.CameraException;
+import org.briarproject.briar.android.keyagreement.CameraView;
+import org.briarproject.briar.android.keyagreement.ContactExchangeErrorFragment;
+import org.briarproject.briar.android.keyagreement.QrCodeDecoder;
+
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.util.logging.Logger;
+
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+
+import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_NOSENSOR;
+import static android.widget.Toast.LENGTH_LONG;
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+import static org.briarproject.bramble.util.LogUtils.logException;
+
+public class ScanQrCodeFragment extends BaseFragment
+		implements QrCodeDecoder.ResultCallback {
+
+	static final String TAG = ScanQrCodeFragment.class.getName();
+
+	private static final Logger LOG = Logger.getLogger(TAG);
+	private static final Charset ISO_8859_1 = Charset.forName("ISO-8859-1");
+
+	@Inject
+	PayloadParser payloadParser;
+	@Inject
+	KeyAgreementController keyAgreementController;
+
+	private CameraView cameraView;
+	private KeyAgreementEventListener listener;
+	private volatile boolean gotRemotePayload;
+
+	public static ScanQrCodeFragment newInstance() {
+		Bundle args = new Bundle();
+		ScanQrCodeFragment fragment = new ScanQrCodeFragment();
+		fragment.setArguments(args);
+		return fragment;
+	}
+
+	@Override
+	public void onAttach(Context context) {
+		super.onAttach(context);
+		listener = (KeyAgreementEventListener) context;
+	}
+
+	@Override
+	public void injectFragment(ActivityComponent component) {
+		component.inject(this);
+	}
+
+	@Override
+	public String getUniqueTag() {
+		return TAG;
+	}
+
+	@Nullable
+	@Override
+	public View onCreateView(LayoutInflater inflater,
+			@Nullable ViewGroup container,
+			@Nullable Bundle savedInstanceState) {
+		return inflater.inflate(R.layout.fragment_keyagreement_scan, container,
+				false);
+	}
+
+	@Override
+	public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
+		super.onViewCreated(view, savedInstanceState);
+		cameraView = view.findViewById(R.id.camera_view);
+	}
+
+	@Override
+	public void onActivityCreated(@Nullable Bundle savedInstanceState) {
+		super.onActivityCreated(savedInstanceState);
+		getActivity().setRequestedOrientation(SCREEN_ORIENTATION_NOSENSOR);
+		cameraView.setPreviewConsumer(new QrCodeDecoder(this));
+	}
+
+	@Override
+	public void onStart() {
+		super.onStart();
+		try {
+			cameraView.start();
+		} catch (CameraException e) {
+			logCameraExceptionAndFinish(e);
+		}
+	}
+
+	@Override
+	public void onStop() {
+		super.onStop();
+		try {
+			cameraView.stop();
+		} catch (CameraException e) {
+			logCameraExceptionAndFinish(e);
+		}
+	}
+
+	@UiThread
+	private void logCameraExceptionAndFinish(CameraException e) {
+		logException(LOG, WARNING, e);
+		Toast.makeText(getActivity(), R.string.camera_error,
+				LENGTH_LONG).show();
+		finish();
+	}
+
+	@Override
+	public void handleResult(Result result) {
+		runOnUiThreadUnlessDestroyed(() -> {
+			LOG.info("Got result from decoder");
+			if (!gotRemotePayload) qrCodeScanned(result.getText());
+		});
+	}
+
+	@UiThread
+	private void qrCodeScanned(String content) {
+		try {
+			byte[] payloadBytes = content.getBytes(ISO_8859_1);
+			if (LOG.isLoggable(INFO))
+				LOG.info("Remote payload is " + payloadBytes.length + " bytes");
+			Payload remotePayload = payloadParser.parse(payloadBytes);
+			gotRemotePayload = true;
+			cameraView.stop();
+			keyAgreementController.setRemoteQrCode(remotePayload);
+			listener.showExchangeFragment();
+		} catch (UnsupportedVersionException e) {
+			String msg = getString(R.string.qr_code_unsupported,
+					getString(R.string.app_name));
+			showNextFragment(ContactExchangeErrorFragment.newInstance(msg));
+		} catch (CameraException e) {
+			logCameraExceptionAndFinish(e);
+		} catch (IOException | IllegalArgumentException e) {
+			LOG.log(WARNING, "QR Code Invalid", e);
+			Toast.makeText(getActivity(), R.string.qr_code_invalid,
+					LENGTH_LONG).show();
+		}
+	}
+
+	@Override
+	protected void finish() {
+		getActivity().getSupportFragmentManager().popBackStack();
+	}
+}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/ShowQrCodeFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/ShowQrCodeFragment.java
new file mode 100644
index 0000000000000000000000000000000000000000..11de9c6019bc400cca108de16b9f573154130a02
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/mailbox/ShowQrCodeFragment.java
@@ -0,0 +1,137 @@
+package org.briarproject.briar.android.keyagreement.mailbox;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.os.Bundle;
+import android.util.DisplayMetrics;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+
+import org.briarproject.bramble.api.event.Event;
+import org.briarproject.bramble.api.keyagreement.Payload;
+import org.briarproject.bramble.api.keyagreement.PayloadEncoder;
+import org.briarproject.bramble.api.lifecycle.IoExecutor;
+import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
+import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
+import org.briarproject.briar.R;
+import org.briarproject.briar.android.activity.ActivityComponent;
+import org.briarproject.briar.android.controller.handler.UiResultHandler;
+import org.briarproject.briar.android.fragment.BaseEventFragment;
+import org.briarproject.briar.android.keyagreement.QrCodeUtils;
+import org.briarproject.briar.android.view.QrCodeView;
+
+import java.nio.charset.Charset;
+import java.util.concurrent.Executor;
+import java.util.logging.Logger;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+
+import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_NOSENSOR;
+import static java.util.logging.Level.INFO;
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+public class ShowQrCodeFragment extends BaseEventFragment {
+	static final String TAG = ShowQrCodeFragment.class.getName();
+
+	private static final Logger LOG = Logger.getLogger(TAG);
+	private static final Charset ISO_8859_1 = Charset.forName("ISO-8859-1");
+
+	@Inject
+	PayloadEncoder payloadEncoder;
+	@Inject
+	@IoExecutor
+	Executor ioExecutor;
+	@Inject
+	KeyAgreementController keyAgreementController;
+
+	private QrCodeView qrCodeView;
+	private Button nextButton;
+
+	private KeyAgreementEventListener listener;
+
+	public static ShowQrCodeFragment newInstance() {
+		Bundle args = new Bundle();
+		ShowQrCodeFragment fragment = new ShowQrCodeFragment();
+		fragment.setArguments(args);
+		return fragment;
+	}
+
+	@Override
+	public void onAttach(Context context) {
+		super.onAttach(context);
+		listener = (KeyAgreementEventListener) context;
+	}
+
+	@Override
+	public void injectFragment(ActivityComponent component) {
+		component.inject(this);
+	}
+
+	@Override
+	public String getUniqueTag() {
+		return TAG;
+	}
+
+	@Nullable
+	@Override
+	public View onCreateView(LayoutInflater inflater,
+			@Nullable ViewGroup container,
+			@Nullable Bundle savedInstanceState) {
+		return inflater
+				.inflate(R.layout.fragment_keyagreement_mailbox_qr, container,
+						false);
+	}
+
+	@Override
+	public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
+		super.onViewCreated(view, savedInstanceState);
+		qrCodeView = view.findViewById(R.id.qr_code_view);
+		nextButton = view.findViewById(R.id.nextButton);
+		nextButton.setOnClickListener((it) -> listener.showCameraFragment());
+	}
+
+	@Override
+	public void onActivityCreated(@Nullable Bundle savedInstanceState) {
+		super.onActivityCreated(savedInstanceState);
+		getActivity().setRequestedOrientation(SCREEN_ORIENTATION_NOSENSOR);
+		keyAgreementController.getQrCode(new UiResultHandler<Payload>(this) {
+			@Override
+			public void onResultUi(@Nonnull Payload payload) {
+				showQrCode(payload);
+			}
+		});
+	}
+
+	private void showQrCode(Payload payload) {
+		Context context = getContext();
+		if (context == null) return;
+		DisplayMetrics dm = context.getResources().getDisplayMetrics();
+		ioExecutor.execute(() -> {
+			byte[] payloadBytes = payloadEncoder.encode(payload);
+			if (LOG.isLoggable(INFO)) {
+				LOG.info("Local payload is " + payloadBytes.length
+						+ " bytes");
+			}
+			// Use ISO 8859-1 to encode bytes directly as a string
+			String content = new String(payloadBytes, ISO_8859_1);
+			Bitmap qrCode = QrCodeUtils.createQrCode(dm, content);
+			runOnUiThreadUnlessDestroyed(() -> {
+				qrCodeView.setQrCode(qrCode);
+			});
+		});
+	}
+
+	@Override
+	protected void finish() {
+		getActivity().getSupportFragmentManager().popBackStack();
+	}
+
+	@Override
+	public void eventOccurred(Event e) {
+	}
+}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/mailbox/MailboxFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/mailbox/MailboxFragment.java
index adccee62803f5d9aad8d5a2161ad368877aec3db..74f93629497c72d67a454e274f5cf42262d4272a 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/mailbox/MailboxFragment.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/mailbox/MailboxFragment.java
@@ -27,7 +27,7 @@ import org.briarproject.bramble.api.plugin.event.ContactDisconnectedEvent;
 import org.briarproject.briar.R;
 import org.briarproject.briar.android.activity.ActivityComponent;
 import org.briarproject.briar.android.fragment.BaseFragment;
-import org.briarproject.briar.android.keyagreement.MailboxExchangeActivity;
+import org.briarproject.briar.android.keyagreement.mailbox.MailboxKeyAgreementActivity;
 
 import java.util.logging.Logger;
 
@@ -99,7 +99,8 @@ public class MailboxFragment extends BaseFragment implements EventListener {
 		switch (item.getItemId()) {
 			case R.id.action_add_contact:
 				Intent intent =
-						new Intent(getContext(), MailboxExchangeActivity.class);
+						new Intent(getContext(),
+								MailboxKeyAgreementActivity.class);
 				startActivity(intent);
 				return true;
 			default:
diff --git a/briar-android/src/main/res/layout/fragment_keyagreement_mailbox_exchange.xml b/briar-android/src/main/res/layout/fragment_keyagreement_mailbox_exchange.xml
new file mode 100644
index 0000000000000000000000000000000000000000..ac8d084f955337d7098eef9a0b6d7750ddcd4cb9
--- /dev/null
+++ b/briar-android/src/main/res/layout/fragment_keyagreement_mailbox_exchange.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<android.support.constraint.ConstraintLayout
+	xmlns:android="http://schemas.android.com/apk/res/android"
+	xmlns:app="http://schemas.android.com/apk/res-auto"
+	android:layout_width="match_parent"
+	android:layout_height="match_parent">
+
+	<TextView
+		android:id="@+id/status"
+		android:layout_width="match_parent"
+		android:layout_height="match_parent"
+		android:gravity="center"
+		android:padding="@dimen/margin_large"
+		android:text="Exchanging contact details"
+		app:layout_constraintBottom_toBottomOf="parent"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toTopOf="parent"/>
+
+</android.support.constraint.ConstraintLayout>
diff --git a/briar-android/src/main/res/layout/fragment_keyagreement_mailbox_qr.xml b/briar-android/src/main/res/layout/fragment_keyagreement_mailbox_qr.xml
new file mode 100644
index 0000000000000000000000000000000000000000..a29c7be4d5c787d5e3fec434ff81a6ecbf25749c
--- /dev/null
+++ b/briar-android/src/main/res/layout/fragment_keyagreement_mailbox_qr.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8"?>
+<android.support.constraint.ConstraintLayout
+	xmlns:android="http://schemas.android.com/apk/res/android"
+	xmlns:app="http://schemas.android.com/apk/res-auto"
+	android:layout_width="match_parent"
+	android:layout_height="match_parent">
+
+	<org.briarproject.briar.android.view.QrCodeView
+		android:id="@+id/qr_code_view"
+		android:layout_width="match_parent"
+		android:layout_height="0dp"
+		android:background="@android:color/white"
+		app:layout_constraintBottom_toTopOf="@+id/instruction"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toTopOf="parent"/>
+
+	<TextView
+		android:id="@+id/instruction"
+		android:layout_width="match_parent"
+		android:layout_height="wrap_content"
+		android:background="@color/window_background"
+		android:gravity="center"
+		android:paddingBottom="@dimen/margin_large"
+		android:paddingTop="@dimen/margin_large"
+		android:text="Scan this QrCode with the phone the Briar Mailbox is running on. Once done press Next."
+		app:layout_constraintBottom_toTopOf="@+id/next"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toBottomOf="@+id/qr_code_view"/>
+
+	<Button
+		android:id="@+id/nextButton"
+		style="@style/BriarButton"
+		android:layout_width="match_parent"
+		android:layout_height="wrap_content"
+		android:text="@string/setup_next"
+		app:layout_constraintBottom_toBottomOf="parent"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toBottomOf="@+id/instruction"/>
+
+</android.support.constraint.ConstraintLayout>
diff --git a/briar-android/src/main/res/layout/fragment_keyagreement_scan.xml b/briar-android/src/main/res/layout/fragment_keyagreement_scan.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f3d0c2db146a1424a869040b5168a6ee06a56162
--- /dev/null
+++ b/briar-android/src/main/res/layout/fragment_keyagreement_scan.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<android.support.constraint.ConstraintLayout
+	android:id="@+id/frameLayout"
+	xmlns:android="http://schemas.android.com/apk/res/android"
+	xmlns:app="http://schemas.android.com/apk/res-auto"
+	xmlns:tools="http://schemas.android.com/tools"
+	android:layout_width="match_parent"
+	android:layout_height="match_parent">
+
+	<org.briarproject.briar.android.keyagreement.CameraView
+		android:id="@+id/camera_view"
+		android:layout_width="match_parent"
+		android:layout_height="match_parent"
+		app:layout_constraintBottom_toBottomOf="parent"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toTopOf="parent"/>
+
+	<TextView
+		android:id="@+id/instruction"
+		android:layout_width="match_parent"
+		android:layout_height="wrap_content"
+		android:background="@color/window_background"
+		android:gravity="center"
+		android:paddingBottom="@dimen/margin_large"
+		android:paddingTop="@dimen/margin_large"
+		android:text="Scan the QrCode on your Briar phone"
+		app:layout_constraintBottom_toBottomOf="parent"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		tools:text="Scan the QrCode on your Briar phone"/>
+
+	<TextView
+		android:id="@+id/status"
+		android:layout_width="match_parent"
+		android:layout_height="match_parent"
+		android:gravity="center"
+		android:padding="@dimen/margin_large"
+		android:text="Exchanging contact details"
+		android:visibility="invisible"
+		app:layout_constraintBottom_toBottomOf="parent"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toTopOf="parent"/>
+
+
+</android.support.constraint.ConstraintLayout>
diff --git a/mailbox-android/src/main/res/layout/fragment_keyagreement_qr.xml b/mailbox-android/src/main/res/layout/fragment_keyagreement_qr.xml
index 449f5b905d5ab535aa492c997e3a85da684824bd..ac6d90d825bdb06538689e78e804ea6ef3ddc87b 100644
--- a/mailbox-android/src/main/res/layout/fragment_keyagreement_qr.xml
+++ b/mailbox-android/src/main/res/layout/fragment_keyagreement_qr.xml
@@ -40,9 +40,9 @@
 		android:max="100"
 		android:progress="100"
 		android:rotation="180"
-		app:layout_constraintTop_toTopOf="@+id/instruction"
 		app:layout_constraintEnd_toEndOf="parent"
-		app:layout_constraintStart_toStartOf="parent"/>
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toTopOf="@+id/instruction"/>
 
 	<TextView
 		android:id="@+id/status"
@@ -52,7 +52,7 @@
 		android:padding="@dimen/margin_large"
 		android:text="Exchanging contact details"
 		android:visibility="invisible"
-		app:layout_constraintBottom_toTopOf="@+id/timer"
+		app:layout_constraintBottom_toBottomOf="parent"
 		app:layout_constraintEnd_toEndOf="parent"
 		app:layout_constraintStart_toStartOf="parent"
 		app:layout_constraintTop_toTopOf="parent"/>