diff --git a/repeater-android/build.gradle b/repeater-android/build.gradle
index ef5439780326aacf247c73f9b822aeea258493b2..0f8df15ccd75a6c0c0bd270afec2979ad4e3d76e 100644
--- a/repeater-android/build.gradle
+++ b/repeater-android/build.gradle
@@ -40,6 +40,7 @@ dependencies {
 	def supportVersion = '27.1.1'
 	compileOnly 'javax.annotation:jsr250-api:1.0'
 	kapt "com.google.dagger:dagger-compiler:2.0.2"
+	implementation 'com.google.zxing:core:3.3.0'
 	implementation "com.android.support:appcompat-v7:$supportVersion"
 	implementation "com.android.support:support-v4:$supportVersion"
 	implementation ("com.android.support:design:$supportVersion") {
diff --git a/repeater-android/src/main/AndroidManifest.xml b/repeater-android/src/main/AndroidManifest.xml
index efb3f93848c1ac82047bccb236bd61b91ebc9b52..1afeed2c670ed0b57c04f3f353ce4cca821820bc 100644
--- a/repeater-android/src/main/AndroidManifest.xml
+++ b/repeater-android/src/main/AndroidManifest.xml
@@ -2,6 +2,22 @@
 <manifest package="org.briarproject.briar.repeater"
           xmlns:android="http://schemas.android.com/apk/res/android">
 
+	<uses-feature android:name="android.hardware.bluetooth"/>
+	<uses-feature android:name="android.hardware.camera"/>
+
+	<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
+	<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
+	<uses-permission android:name="android.permission.BLUETOOTH"/>
+	<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
+	<uses-permission android:name="android.permission.CAMERA"/>
+	<uses-permission android:name="android.permission.INTERNET"/>
+	<uses-permission android:name="android.permission.VIBRATE"/>
+	<uses-permission android:name="android.permission.WAKE_LOCK"/>
+	<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
+
+	<uses-permission-sdk-23 android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
+
+
 	<application
 		android:name="org.briarproject.repeater.RepeaterApplication"
 		android:allowBackup="false"
@@ -19,6 +35,16 @@
 				<category android:name="android.intent.category.LAUNCHER"/>
 			</intent-filter>
 		</activity>
+		<activity
+			android:name="org.briarproject.repeater.keyagreement.ContactExchangeActivity"
+			android:label="@string/add_contact_title"
+			android:parentActivityName="org.briarproject.repeater.activity.NavDrawerActivity"
+			android:theme="@style/BriarTheme.NoActionBar">
+			<meta-data
+				android:name="android.support.PARENT_ACTIVITY"
+				android:value="org.briarproject.repeater.activity.NavDrawerActivity"/>
+		</activity>
+
 		<service
 			android:name="org.briarproject.repeater.RepeaterService"
 			android:exported="false">
diff --git a/repeater-android/src/main/java/org/briarproject/repeater/activity/BriarActivity.java b/repeater-android/src/main/java/org/briarproject/repeater/activity/BriarActivity.java
new file mode 100644
index 0000000000000000000000000000000000000000..ce20beef19bbc3a7025c5f19043a8cfbae3789bc
--- /dev/null
+++ b/repeater-android/src/main/java/org/briarproject/repeater/activity/BriarActivity.java
@@ -0,0 +1,62 @@
+package org.briarproject.repeater.activity;
+
+import android.annotation.SuppressLint;
+import android.support.v7.app.ActionBar;
+import android.support.v7.widget.Toolbar;
+
+
+import org.briarproject.briar.repeater.R;
+
+import java.util.logging.Logger;
+
+import javax.annotation.Nullable;
+
+import static android.os.Build.VERSION.SDK_INT;
+
+@SuppressLint("Registered")
+public abstract class BriarActivity extends BaseActivity {
+
+	public static final String GROUP_ID = "briar.GROUP_ID";
+	public static final String GROUP_NAME = "briar.GROUP_NAME";
+
+	private static final Logger LOG =
+			Logger.getLogger(BriarActivity.class.getName());
+
+	/**
+	 * This should be called after the content view has been added in onCreate()
+	 *
+	 * @param ownLayout true if the custom toolbar brings its own layout
+	 * @return the Toolbar object or null if content view did not contain one
+	 */
+	@Nullable
+	protected Toolbar setUpCustomToolbar(boolean ownLayout) {
+		// Custom Toolbar
+		Toolbar toolbar = findViewById(R.id.toolbar);
+		setSupportActionBar(toolbar);
+		ActionBar ab = getSupportActionBar();
+		if (ab != null) {
+			ab.setDisplayShowHomeEnabled(true);
+			ab.setDisplayHomeAsUpEnabled(true);
+			ab.setDisplayShowCustomEnabled(ownLayout);
+			ab.setDisplayShowTitleEnabled(!ownLayout);
+		}
+		return toolbar;
+	}
+
+
+	private void exit(boolean removeFromRecentApps) {
+		finishAndExit();
+	}
+
+	private void finishAndExit() {
+		if (SDK_INT >= 21) finishAndRemoveTask();
+		else supportFinishAfterTransition();
+		LOG.info("Exiting");
+		System.exit(0);
+	}
+
+	@Deprecated
+	protected void finishOnUiThread() {
+		runOnUiThreadUnlessDestroyed(this::supportFinishAfterTransition);
+	}
+}
diff --git a/repeater-android/src/main/java/org/briarproject/repeater/activity/RequestCodes.java b/repeater-android/src/main/java/org/briarproject/repeater/activity/RequestCodes.java
new file mode 100644
index 0000000000000000000000000000000000000000..62b9ed96205d9abed00f013ceb7a18144dcb5c1c
--- /dev/null
+++ b/repeater-android/src/main/java/org/briarproject/repeater/activity/RequestCodes.java
@@ -0,0 +1,16 @@
+package org.briarproject.repeater.activity;
+
+public interface RequestCodes {
+
+	int REQUEST_PASSWORD = 1;
+	int REQUEST_INTRODUCTION = 2;
+	int REQUEST_GROUP_INVITE = 3;
+	int REQUEST_SHARE_FORUM = 4;
+	int REQUEST_WRITE_BLOG_POST = 5;
+	int REQUEST_SHARE_BLOG = 6;
+	int REQUEST_RINGTONE = 7;
+	int REQUEST_PERMISSION_CAMERA = 8;
+	int REQUEST_DOZE_WHITELISTING = 9;
+	int REQUEST_ENABLE_BLUETOOTH = 10;
+
+}
diff --git a/repeater-android/src/main/java/org/briarproject/repeater/fragment/BaseEventFragment.java b/repeater-android/src/main/java/org/briarproject/repeater/fragment/BaseEventFragment.java
new file mode 100644
index 0000000000000000000000000000000000000000..4a7ce64a427510c6aa8fa4205dc0268fb932f830
--- /dev/null
+++ b/repeater-android/src/main/java/org/briarproject/repeater/fragment/BaseEventFragment.java
@@ -0,0 +1,25 @@
+package org.briarproject.repeater.fragment;
+
+import org.briarproject.bramble.api.event.EventBus;
+import org.briarproject.bramble.api.event.EventListener;
+
+import javax.inject.Inject;
+
+public abstract class BaseEventFragment extends BaseFragment implements
+		EventListener {
+
+	@Inject
+	protected volatile EventBus eventBus;
+
+	@Override
+	public void onStart() {
+		super.onStart();
+		eventBus.addListener(this);
+	}
+
+	@Override
+	public void onStop() {
+		super.onStop();
+		eventBus.removeListener(this);
+	}
+}
diff --git a/repeater-android/src/main/java/org/briarproject/repeater/fragment/ErrorFragment.java b/repeater-android/src/main/java/org/briarproject/repeater/fragment/ErrorFragment.java
new file mode 100644
index 0000000000000000000000000000000000000000..4b1c3eaa3f9b5d7baf07b8f8d5855610f5221421
--- /dev/null
+++ b/repeater-android/src/main/java/org/briarproject/repeater/fragment/ErrorFragment.java
@@ -0,0 +1,65 @@
+package org.briarproject.repeater.fragment;
+
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
+import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
+import org.briarproject.briar.repeater.R;
+import org.briarproject.repeater.activity.ActivityComponent;
+
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+public class ErrorFragment extends BaseFragment {
+
+	private static final String TAG = ErrorFragment.class.getSimpleName();
+
+	private static final String ERROR_MSG = "errorMessage";
+
+	public static ErrorFragment newInstance(String message) {
+		ErrorFragment f = new ErrorFragment();
+		Bundle args = new Bundle();
+		args.putString(ERROR_MSG, message);
+		f.setArguments(args);
+		return f;
+	}
+
+	private String errorMessage;
+
+	@Override
+	public String getUniqueTag() {
+		return TAG;
+	}
+
+	@Override
+	public void onCreate(@Nullable Bundle savedInstanceState) {
+		super.onCreate(savedInstanceState);
+
+		Bundle args = getArguments();
+		if (args == null) throw new AssertionError();
+		errorMessage = args.getString(ERROR_MSG);
+	}
+
+	@Nullable
+	@Override
+	public View onCreateView(LayoutInflater inflater,
+			@Nullable ViewGroup container,
+			@Nullable Bundle savedInstanceState) {
+		View v = inflater
+				.inflate(R.layout.fragment_error, container, false);
+		TextView msg = v.findViewById(R.id.errorMessage);
+		msg.setText(errorMessage);
+		return v;
+	}
+
+	@Override
+	public void injectFragment(ActivityComponent component) {
+		// not necessary
+	}
+
+}
diff --git a/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/CameraException.java b/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/CameraException.java
new file mode 100644
index 0000000000000000000000000000000000000000..61520bfb811fae63bf8f1a197fd32e5bc81e3647
--- /dev/null
+++ b/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/CameraException.java
@@ -0,0 +1,14 @@
+package org.briarproject.repeater.keyagreement;
+
+import java.io.IOException;
+
+class CameraException extends IOException {
+
+	CameraException(String message) {
+		super(message);
+	}
+
+	CameraException(Throwable cause) {
+		super(cause);
+	}
+}
diff --git a/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/CameraView.java b/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/CameraView.java
new file mode 100644
index 0000000000000000000000000000000000000000..117de7748aed71218761504a8af91feacd18a8f5
--- /dev/null
+++ b/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/CameraView.java
@@ -0,0 +1,524 @@
+package org.briarproject.repeater.keyagreement;
+
+import android.content.Context;
+import android.hardware.Camera;
+import android.hardware.Camera.AutoFocusCallback;
+import android.hardware.Camera.CameraInfo;
+import android.hardware.Camera.Parameters;
+import android.hardware.Camera.Size;
+import android.support.annotation.Nullable;
+import android.support.annotation.UiThread;
+import android.util.AttributeSet;
+import android.view.Display;
+import android.view.Surface;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.view.View;
+import android.view.WindowManager;
+
+import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
+import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.logging.Logger;
+
+import static android.content.Context.WINDOW_SERVICE;
+import static android.hardware.Camera.CameraInfo.CAMERA_FACING_BACK;
+import static android.hardware.Camera.CameraInfo.CAMERA_FACING_FRONT;
+import static android.hardware.Camera.Parameters.FLASH_MODE_OFF;
+import static android.hardware.Camera.Parameters.FOCUS_MODE_AUTO;
+import static android.hardware.Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE;
+import static android.hardware.Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO;
+import static android.hardware.Camera.Parameters.FOCUS_MODE_EDOF;
+import static android.hardware.Camera.Parameters.FOCUS_MODE_FIXED;
+import static android.hardware.Camera.Parameters.FOCUS_MODE_MACRO;
+import static android.hardware.Camera.Parameters.SCENE_MODE_AUTO;
+import static android.hardware.Camera.Parameters.SCENE_MODE_BARCODE;
+import static android.os.Build.VERSION.SDK_INT;
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+import static org.briarproject.bramble.util.LogUtils.logException;
+
+@SuppressWarnings("deprecation")
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+public class CameraView extends SurfaceView implements SurfaceHolder.Callback,
+		AutoFocusCallback, View.OnClickListener {
+
+	// Heuristic for the ideal preview size - small previews don't have enough
+	// detail, large previews are slow to decode
+	private static final int IDEAL_PIXELS = 500 * 1000;
+
+	private static final int AUTO_FOCUS_RETRY_DELAY = 5000; // Milliseconds
+
+	private static final Logger LOG =
+			Logger.getLogger(CameraView.class.getName());
+
+	private final Runnable autoFocusRetry = this::retryAutoFocus;
+
+	@Nullable
+	private Camera camera = null;
+	private int cameraIndex = 0;
+	private PreviewConsumer previewConsumer = null;
+	private Surface surface = null;
+	private int displayOrientation = 0, surfaceWidth = 0, surfaceHeight = 0;
+	private boolean previewStarted = false;
+	private boolean autoFocusSupported = false, autoFocusRunning = false;
+
+	public CameraView(Context context) {
+		super(context);
+	}
+
+	public CameraView(Context context, AttributeSet attrs) {
+		super(context, attrs);
+	}
+
+	public CameraView(Context context, AttributeSet attrs, int defStyleAttr) {
+		super(context, attrs, defStyleAttr);
+	}
+
+	@UiThread
+	public void setPreviewConsumer(PreviewConsumer previewConsumer) {
+		LOG.info("Setting preview consumer");
+		this.previewConsumer = previewConsumer;
+	}
+
+	@Override
+	protected void onAttachedToWindow() {
+		super.onAttachedToWindow();
+		setKeepScreenOn(true);
+		getHolder().addCallback(this);
+		setOnClickListener(this);
+	}
+
+	@Override
+	protected void onDetachedFromWindow() {
+		super.onDetachedFromWindow();
+		setKeepScreenOn(false);
+		getHolder().removeCallback(this);
+	}
+
+	@UiThread
+	public void start() throws CameraException {
+		LOG.info("Opening camera");
+		try {
+			int cameras = Camera.getNumberOfCameras();
+			if (cameras == 0) throw new CameraException("No camera");
+			// Try to find a back-facing camera
+			for (int i = 0; i < cameras; i++) {
+				CameraInfo info = new CameraInfo();
+				Camera.getCameraInfo(i, info);
+				if (info.facing == CAMERA_FACING_BACK) {
+					LOG.info("Using back-facing camera");
+					camera = Camera.open(i);
+					cameraIndex = i;
+					break;
+				}
+			}
+			// If we can't find a back-facing camera, use a front-facing one
+			if (camera == null) {
+				LOG.info("Using front-facing camera");
+				camera = Camera.open(0);
+				cameraIndex = 0;
+			}
+		} catch (RuntimeException e) {
+			throw new CameraException(e);
+		}
+		setDisplayOrientation(getScreenRotationDegrees());
+		// Use barcode scene mode if it's available
+		Parameters params = camera.getParameters();
+		params = setSceneMode(camera, params);
+		if (SCENE_MODE_BARCODE.equals(params.getSceneMode())) {
+			// If the scene mode enabled the flash, try to disable it
+			if (!FLASH_MODE_OFF.equals(params.getFlashMode()))
+				params = disableFlash(camera, params);
+			// If the flash is still enabled, disable the scene mode
+			if (!FLASH_MODE_OFF.equals(params.getFlashMode()))
+				params = disableSceneMode(camera, params);
+		}
+		// Use the best available focus mode, preview size and other options
+		params = setBestParameters(camera, params);
+		// Enable auto focus if the selected focus mode uses it
+		enableAutoFocus(params.getFocusMode());
+		// Log the parameters that are being used (maybe not what we asked for)
+		logCameraParameters();
+		// Start the preview when the camera and the surface are both ready
+		if (surface != null && !previewStarted) startPreview(getHolder());
+	}
+
+	@UiThread
+	public void stop() throws CameraException {
+		if (camera == null) return;
+		stopPreview();
+		LOG.info("Releasing camera");
+		try {
+			camera.release();
+		} catch (RuntimeException e) {
+			throw new CameraException(e);
+		}
+		camera = null;
+	}
+
+	/**
+	 * See {@link Camera#setDisplayOrientation(int)}.
+	 */
+	private int getScreenRotationDegrees() {
+		WindowManager wm =
+				(WindowManager) getContext().getSystemService(WINDOW_SERVICE);
+		Display d = wm.getDefaultDisplay();
+		switch (d.getRotation()) {
+			case Surface.ROTATION_0:
+				return 0;
+			case Surface.ROTATION_90:
+				return 90;
+			case Surface.ROTATION_180:
+				return 180;
+			case Surface.ROTATION_270:
+				return 270;
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@UiThread
+	private void startPreview(SurfaceHolder holder) throws CameraException {
+		LOG.info("Starting preview");
+		if (camera == null) throw new CameraException("Camera is null");
+		try {
+			camera.setPreviewDisplay(holder);
+			camera.startPreview();
+			previewStarted = true;
+			startConsumer();
+		} catch (IOException | RuntimeException e) {
+			throw new CameraException(e);
+		}
+	}
+
+	@UiThread
+	private void stopPreview() throws CameraException {
+		LOG.info("Stopping preview");
+		if (camera == null) throw new CameraException("Camera is null");
+		try {
+			stopConsumer();
+			camera.stopPreview();
+		} catch (RuntimeException e) {
+			throw new CameraException(e);
+		}
+		previewStarted = false;
+	}
+
+	@UiThread
+	private void startConsumer() throws CameraException {
+		if (camera == null) throw new CameraException("Camera is null");
+		startAutoFocus();
+		previewConsumer.start(camera, cameraIndex);
+	}
+
+	@UiThread
+	private void startAutoFocus() throws CameraException {
+		if (camera != null && autoFocusSupported && !autoFocusRunning) {
+			try {
+				removeCallbacks(autoFocusRetry);
+				camera.autoFocus(this);
+				autoFocusRunning = true;
+			} catch (RuntimeException e) {
+				throw new CameraException(e);
+			}
+		}
+	}
+
+	@UiThread
+	private void stopConsumer() throws CameraException {
+		if (camera == null) throw new CameraException("Camera is null");
+		cancelAutoFocus();
+		previewConsumer.stop();
+	}
+
+	@UiThread
+	private void cancelAutoFocus() throws CameraException {
+		if (camera != null && autoFocusSupported && autoFocusRunning) {
+			try {
+				removeCallbacks(autoFocusRetry);
+				camera.cancelAutoFocus();
+				autoFocusRunning = false;
+			} catch (RuntimeException e) {
+				throw new CameraException(e);
+			}
+		}
+	}
+
+	/**
+	 * See {@link Camera#setDisplayOrientation(int)}.
+	 */
+	@UiThread
+	private void setDisplayOrientation(int rotationDegrees)
+			throws CameraException {
+		if (camera == null) throw new CameraException("Camera is null");
+		int orientation;
+		CameraInfo info = new CameraInfo();
+		try {
+			Camera.getCameraInfo(cameraIndex, info);
+		} catch (RuntimeException e) {
+			throw new CameraException(e);
+		}
+		if (info.facing == CAMERA_FACING_FRONT) {
+			orientation = (info.orientation + rotationDegrees) % 360;
+			orientation = (360 - orientation) % 360;
+		} else {
+			orientation = (info.orientation - rotationDegrees + 360) % 360;
+		}
+		if (LOG.isLoggable(INFO)) {
+			LOG.info("Screen rotation " + rotationDegrees
+					+ " degrees, camera orientation " + orientation
+					+ " degrees");
+		}
+		try {
+			camera.setDisplayOrientation(orientation);
+		} catch (RuntimeException e) {
+			throw new CameraException(e);
+		}
+		displayOrientation = orientation;
+	}
+
+	@UiThread
+	private Parameters setSceneMode(Camera camera, Parameters params)
+			throws CameraException {
+		List<String> sceneModes = params.getSupportedSceneModes();
+		if (sceneModes == null) return params;
+		if (LOG.isLoggable(INFO)) LOG.info("Scene modes: " + sceneModes);
+		if (sceneModes.contains(SCENE_MODE_BARCODE)) {
+			params.setSceneMode(SCENE_MODE_BARCODE);
+			try {
+				camera.setParameters(params);
+				return camera.getParameters();
+			} catch (RuntimeException e) {
+				throw new CameraException(e);
+			}
+		}
+		return params;
+	}
+
+	@UiThread
+	private Parameters disableFlash(Camera camera, Parameters params)
+			throws CameraException {
+		params.setFlashMode(FLASH_MODE_OFF);
+		try {
+			camera.setParameters(params);
+			return camera.getParameters();
+		} catch (RuntimeException e) {
+			throw new CameraException(e);
+		}
+	}
+
+	@UiThread
+	private Parameters disableSceneMode(Camera camera, Parameters params)
+			throws CameraException {
+		params.setSceneMode(SCENE_MODE_AUTO);
+		try {
+			camera.setParameters(params);
+			return camera.getParameters();
+		} catch (RuntimeException e) {
+			throw new CameraException(e);
+		}
+	}
+
+	@UiThread
+	private Parameters setBestParameters(Camera camera, Parameters params)
+			throws CameraException {
+		setVideoStabilisation(params);
+		setFocusMode(params);
+		params.setFlashMode(FLASH_MODE_OFF);
+		setPreviewSize(params);
+		try {
+			camera.setParameters(params);
+			return camera.getParameters();
+		} catch (RuntimeException e) {
+			throw new CameraException(e);
+		}
+	}
+
+	@UiThread
+	private void setVideoStabilisation(Parameters params) {
+		if (SDK_INT >= 15 && params.isVideoStabilizationSupported()) {
+			params.setVideoStabilization(true);
+		}
+	}
+
+	@UiThread
+	private void setFocusMode(Parameters params) {
+		List<String> focusModes = params.getSupportedFocusModes();
+		if (LOG.isLoggable(INFO)) LOG.info("Focus modes: " + focusModes);
+		if (focusModes.contains(FOCUS_MODE_CONTINUOUS_PICTURE)) {
+			params.setFocusMode(FOCUS_MODE_CONTINUOUS_PICTURE);
+		} else if (focusModes.contains(FOCUS_MODE_CONTINUOUS_VIDEO)) {
+			params.setFocusMode(FOCUS_MODE_CONTINUOUS_VIDEO);
+		} else if (focusModes.contains(FOCUS_MODE_EDOF)) {
+			params.setFocusMode(FOCUS_MODE_EDOF);
+		} else if (focusModes.contains(FOCUS_MODE_MACRO)) {
+			params.setFocusMode(FOCUS_MODE_MACRO);
+		} else if (focusModes.contains(FOCUS_MODE_AUTO)) {
+			params.setFocusMode(FOCUS_MODE_AUTO);
+		} else if (focusModes.contains(FOCUS_MODE_FIXED)) {
+			params.setFocusMode(FOCUS_MODE_FIXED);
+		}
+	}
+
+	@UiThread
+	private void setPreviewSize(Parameters params) {
+		if (surfaceWidth == 0 || surfaceHeight == 0) return;
+		// Choose a preview size that's close to the aspect ratio of the
+		// surface and close to the ideal size for decoding
+		float idealRatio = (float) surfaceWidth / surfaceHeight;
+		boolean rotatePreview = displayOrientation % 180 == 90;
+		List<Size> sizes = params.getSupportedPreviewSizes();
+		Size bestSize = null;
+		float bestScore = 0;
+		for (Size size : sizes) {
+			int width = rotatePreview ? size.height : size.width;
+			int height = rotatePreview ? size.width : size.height;
+			float ratio = (float) width / height;
+			float stretch = Math.max(ratio / idealRatio, idealRatio / ratio);
+			float pixels = width * height;
+			float zoom = Math.max(pixels / IDEAL_PIXELS, IDEAL_PIXELS / pixels);
+			float score = 1 / (stretch * zoom);
+			if (LOG.isLoggable(INFO)) {
+				LOG.info("Size " + size.width + "x" + size.height
+						+ ", stretch " + stretch + ", zoom " + zoom
+						+ ", score " + score);
+			}
+			if (bestSize == null || score > bestScore) {
+				bestSize = size;
+				bestScore = score;
+			}
+		}
+		if (bestSize != null) {
+			if (LOG.isLoggable(INFO))
+				LOG.info("Best size " + bestSize.width + "x" + bestSize.height);
+			params.setPreviewSize(bestSize.width, bestSize.height);
+		}
+	}
+
+	@UiThread
+	private void enableAutoFocus(String focusMode) {
+		autoFocusSupported = FOCUS_MODE_AUTO.equals(focusMode) ||
+				FOCUS_MODE_MACRO.equals(focusMode);
+	}
+
+	@UiThread
+	private void logCameraParameters() throws CameraException {
+		if (camera == null) throw new AssertionError();
+		if (LOG.isLoggable(INFO)) {
+			Parameters params;
+			try {
+				params = camera.getParameters();
+			} catch (RuntimeException e) {
+				throw new CameraException(e);
+			}
+			if (SDK_INT >= 15) {
+				LOG.info("Video stabilisation enabled: "
+						+ params.getVideoStabilization());
+			}
+			LOG.info("Scene mode: " + params.getSceneMode());
+			LOG.info("Focus mode: " + params.getFocusMode());
+			LOG.info("Flash mode: " + params.getFlashMode());
+			Size size = params.getPreviewSize();
+			LOG.info("Preview size: " + size.width + "x" + size.height);
+		}
+	}
+
+	@Override
+	public void surfaceCreated(SurfaceHolder holder) {
+		post(() -> {
+			try {
+				surfaceCreatedUi(holder);
+			} catch (CameraException e) {
+				logException(LOG, WARNING, e);
+			}
+		});
+	}
+
+	@UiThread
+	private void surfaceCreatedUi(SurfaceHolder holder) throws CameraException {
+		LOG.info("Surface created");
+		if (surface != null && surface != holder.getSurface()) {
+			LOG.info("Releasing old surface");
+			surface.release();
+		}
+		surface = holder.getSurface();
+		// We'll start the preview when surfaceChanged() is called
+	}
+
+	@Override
+	public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) {
+		post(() -> {
+			try {
+				surfaceChangedUi(holder, w, h);
+			} catch (CameraException e) {
+				logException(LOG, WARNING, e);
+			}
+		});
+	}
+
+	@UiThread
+	private void surfaceChangedUi(SurfaceHolder holder, int w, int h)
+			throws CameraException {
+		if (LOG.isLoggable(INFO)) LOG.info("Surface changed: " + w + "x" + h);
+		if (surface != null && surface != holder.getSurface()) {
+			LOG.info("Releasing old surface");
+			surface.release();
+		}
+		surface = holder.getSurface();
+		surfaceWidth = w;
+		surfaceHeight = h;
+		if (camera == null) return; // We are stopped
+		if (previewStarted) stopPreview();
+		try {
+			Parameters params = camera.getParameters();
+			setPreviewSize(params);
+			camera.setParameters(params);
+			logCameraParameters();
+		} catch (RuntimeException e) {
+			throw new CameraException(e);
+		}
+		startPreview(holder);
+	}
+
+	@Override
+	public void surfaceDestroyed(SurfaceHolder holder) {
+		post(() -> surfaceDestroyedUi(holder));
+	}
+
+	@UiThread
+	private void surfaceDestroyedUi(SurfaceHolder holder) {
+		LOG.info("Surface destroyed");
+		if (surface != null && surface != holder.getSurface()) {
+			LOG.info("Releasing old surface");
+			surface.release();
+		}
+		surface = null;
+		holder.getSurface().release();
+	}
+
+	@Override
+	public void onAutoFocus(boolean success, Camera camera) {
+		if (LOG.isLoggable(INFO))
+			LOG.info("Auto focus succeeded: " + success);
+		autoFocusRunning = false;
+		postDelayed(autoFocusRetry, AUTO_FOCUS_RETRY_DELAY);
+	}
+
+	@UiThread
+	private void retryAutoFocus() {
+		try {
+			startAutoFocus();
+		} catch (CameraException e) {
+			logException(LOG, WARNING, e);
+		}
+	}
+
+	@Override
+	public void onClick(View v) {
+		retryAutoFocus();
+	}
+}
\ No newline at end of file
diff --git a/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/ContactExchangeActivity.java b/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/ContactExchangeActivity.java
new file mode 100644
index 0000000000000000000000000000000000000000..31c6f7d1d6127022b2dbe80a6b41742bc95130cc
--- /dev/null
+++ b/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/ContactExchangeActivity.java
@@ -0,0 +1,141 @@
+package org.briarproject.repeater.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.bramble.api.nullsafety.MethodsNotNullByDefault;
+import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
+import org.briarproject.repeater.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.util.LogUtils.logException;
+import static org.briarproject.briar.repeater.R.*;
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+public class ContactExchangeActivity extends KeyAgreementActivity implements
+		ContactExchangeListener {
+
+	private static final Logger LOG =
+			Logger.getLogger(ContactExchangeActivity.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(string.add_contact_title);
+	}
+
+	private void startContactExchange(KeyAgreementResult result) {
+		LocalAuthor localAuthor;
+		// Load the local pseudonym
+		try {
+			localAuthor = identityManager.getLocalAuthor();
+		} catch (DbException e) {
+			logException(LOG, WARNING, e);
+			contactExchangeFailed();
+			return;
+		}
+
+		// Exchange contact details
+		contactExchangeTask.startExchange(ContactExchangeActivity.this,
+				localAuthor, result.getMasterKey(),
+				result.getConnection(), result.getTransportId(),
+				result.wasAlice());
+	}
+
+	@Override
+	public void contactExchangeSucceeded(Author remoteAuthor) {
+		runOnUiThreadUnlessDestroyed(() -> {
+			String contactName = remoteAuthor.getName();
+			String format = getString(string.contact_added_toast);
+			String text = String.format(format, contactName);
+			Toast.makeText(ContactExchangeActivity.this, text, LENGTH_LONG)
+					.show();
+			supportFinishAfterTransition();
+		});
+	}
+
+	@Override
+	public void duplicateContact(Author remoteAuthor) {
+		runOnUiThreadUnlessDestroyed(() -> {
+			String contactName = remoteAuthor.getName();
+			String format = getString(string.contact_already_exists);
+			String text = String.format(format, contactName);
+			Toast.makeText(ContactExchangeActivity.this, text, LENGTH_LONG)
+					.show();
+			finish();
+		});
+	}
+
+	@Override
+	public void contactExchangeFailed() {
+		runOnUiThreadUnlessDestroyed(() -> {
+			Toast.makeText(ContactExchangeActivity.this,
+					string.contact_exchange_failed, LENGTH_LONG).show();
+			finish();
+		});
+	}
+
+	@UiThread
+	@Override
+	public void keyAgreementFailed() {
+		// TODO show failure somewhere persistent?
+		Toast.makeText(this, string.connection_failed,
+				LENGTH_LONG).show();
+	}
+
+	@UiThread
+	@Override
+	public String keyAgreementWaiting() {
+		return getString(string.waiting_for_contact_to_scan);
+	}
+
+	@UiThread
+	@Override
+	public String keyAgreementStarted() {
+		return getString(string.authenticating_with_device);
+	}
+
+	@UiThread
+	@Override
+	public String keyAgreementAborted(boolean remoteAborted) {
+		// TODO show abort somewhere persistent?
+		Toast.makeText(this,
+				remoteAborted ? string.connection_aborted_remote :
+						string.connection_aborted_local, LENGTH_LONG)
+				.show();
+		return null;
+	}
+
+	@UiThread
+	@Override
+	public String keyAgreementFinished(KeyAgreementResult result) {
+		startContactExchange(result);
+		return getString(string.exchanging_contact_details);
+	}
+}
diff --git a/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/IntroFragment.java b/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/IntroFragment.java
new file mode 100644
index 0000000000000000000000000000000000000000..5a19a86bb7f222813e8589f7b59b1916af53ddda
--- /dev/null
+++ b/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/IntroFragment.java
@@ -0,0 +1,79 @@
+package org.briarproject.repeater.keyagreement;
+
+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.repeater.R;
+import org.briarproject.repeater.activity.ActivityComponent;
+
+import javax.annotation.Nullable;
+
+import org.briarproject.repeater.fragment.BaseFragment;
+
+import static android.view.View.FOCUS_DOWN;
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+public class IntroFragment extends BaseFragment {
+
+	interface IntroScreenSeenListener {
+		void showNextScreen();
+	}
+
+	public static final String TAG = IntroFragment.class.getName();
+
+	private IntroScreenSeenListener screenSeenListener;
+	private ScrollView scrollView;
+
+	public static IntroFragment newInstance() {
+
+		Bundle args = new Bundle();
+
+		IntroFragment fragment = new IntroFragment();
+		fragment.setArguments(args);
+		return fragment;
+	}
+
+	@Override
+	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/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/KeyAgreementActivity.java b/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/KeyAgreementActivity.java
new file mode 100644
index 0000000000000000000000000000000000000000..c59c85ba64b6b54e1d500a6cd3cf0f58c7e17895
--- /dev/null
+++ b/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/KeyAgreementActivity.java
@@ -0,0 +1,257 @@
+package org.briarproject.repeater.keyagreement;
+
+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.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.event.EventBus;
+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.repeater.activity.ActivityComponent;
+
+import java.util.logging.Logger;
+
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+
+import org.briarproject.repeater.activity.BriarActivity;
+import org.briarproject.repeater.fragment.BaseFragment;
+
+import static org.briarproject.repeater.activity.RequestCodes.REQUEST_ENABLE_BLUETOOTH;
+import static org.briarproject.repeater.activity.RequestCodes.REQUEST_PERMISSION_CAMERA;
+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.repeater.R.*;
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+public abstract class KeyAgreementActivity extends BriarActivity implements
+		BaseFragment.BaseFragmentListener, IntroFragment.IntroScreenSeenListener,
+		KeyAgreementFragment.KeyAgreementEventListener {
+
+	private enum BluetoothState {
+		UNKNOWN, NO_ADAPTER, WAITING, REFUSED, ENABLED
+	}
+
+	private static final Logger LOG =
+			Logger.getLogger(KeyAgreementActivity.class.getName());
+
+	@Inject
+	EventBus eventBus;
+
+	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);
+		setContentView(layout.activity_fragment_container_toolbar);
+		Toolbar toolbar = findViewById(id.toolbar);
+		setSupportActionBar(toolbar);
+		getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+		if (state == null) {
+			showInitialFragment(IntroFragment.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()) showQrCodeFragment();
+	}
+
+	private boolean canShowQrCodeFragment() {
+		return isResumed && continueClicked
+				&& (SDK_INT < 23 || gotCameraPermission)
+				&& bluetoothState != BluetoothState.UNKNOWN
+				&& bluetoothState != BluetoothState.WAITING;
+	}
+
+	@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()) showQrCodeFragment();
+	}
+
+	@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);
+	}
+
+	private void showQrCodeFragment() {
+		continueClicked = false;
+		// FIXME #824
+		FragmentManager fm = getSupportFragmentManager();
+		if (fm.findFragmentByTag(KeyAgreementFragment.TAG) == null) {
+			BaseFragment f = KeyAgreementFragment.newInstance(this);
+			fm.beginTransaction()
+					.replace(id.fragmentContainer, f, f.getUniqueTag())
+					.addToBackStack(f.getUniqueTag())
+					.commit();
+		}
+	}
+
+	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, style.BriarDialogTheme);
+				builder.setTitle(string.permission_camera_title);
+				builder.setMessage(string.permission_camera_request_body);
+				builder.setNeutralButton(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, style.BriarDialogTheme);
+					builder.setTitle(string.permission_camera_title);
+					builder.setMessage(string.permission_camera_denied_body);
+					builder.setPositiveButton(string.ok,
+							UiUtils.getGoToSettingsListener(this));
+					builder.setNegativeButton(string.cancel, cancelListener);
+					builder.show();
+					*/
+				} else {
+					Toast.makeText(this, string.permission_camera_denied_toast,
+							LENGTH_LONG).show();
+					supportFinishAfterTransition();
+				}
+			}
+		}
+	}
+
+	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/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/KeyAgreementFragment.java b/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/KeyAgreementFragment.java
new file mode 100644
index 0000000000000000000000000000000000000000..2b26b63dd0b02d39d5b206eee0601c055053de8b
--- /dev/null
+++ b/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/KeyAgreementFragment.java
@@ -0,0 +1,372 @@
+package org.briarproject.repeater.keyagreement;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.os.Bundle;
+import android.support.annotation.UiThread;
+import android.util.DisplayMetrics;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+import android.widget.LinearLayout.LayoutParams;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.google.zxing.Result;
+
+import org.briarproject.bramble.api.UnsupportedVersionException;
+import org.briarproject.bramble.api.event.Event;
+import org.briarproject.bramble.api.event.EventBus;
+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.PayloadEncoder;
+import org.briarproject.bramble.api.keyagreement.PayloadParser;
+import org.briarproject.bramble.api.keyagreement.event.KeyAgreementAbortedEvent;
+import org.briarproject.bramble.api.keyagreement.event.KeyAgreementFailedEvent;
+import org.briarproject.bramble.api.keyagreement.event.KeyAgreementFinishedEvent;
+import org.briarproject.bramble.api.keyagreement.event.KeyAgreementListeningEvent;
+import org.briarproject.bramble.api.keyagreement.event.KeyAgreementStartedEvent;
+import org.briarproject.bramble.api.keyagreement.event.KeyAgreementWaitingEvent;
+import org.briarproject.bramble.api.lifecycle.IoExecutor;
+import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
+import org.briarproject.briar.repeater.R;
+import org.briarproject.repeater.activity.ActivityComponent;
+import org.briarproject.repeater.view.QrCodeView;
+
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.util.concurrent.Executor;
+import java.util.logging.Logger;
+
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+import javax.inject.Provider;
+
+import org.briarproject.repeater.fragment.BaseEventFragment;
+import org.briarproject.repeater.fragment.ErrorFragment;
+
+import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_NOSENSOR;
+import static android.view.View.INVISIBLE;
+import static android.view.View.VISIBLE;
+import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
+import static android.widget.LinearLayout.HORIZONTAL;
+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;
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+public class KeyAgreementFragment extends BaseEventFragment
+		implements QrCodeDecoder.ResultCallback, QrCodeView.FullscreenListener {
+
+	static final String TAG = KeyAgreementFragment.class.getName();
+
+	private static final Logger LOG = Logger.getLogger(TAG);
+	private static final Charset ISO_8859_1 = Charset.forName("ISO-8859-1");
+
+	@Inject
+	Provider<KeyAgreementTask> keyAgreementTaskProvider;
+	@Inject
+	PayloadEncoder payloadEncoder;
+	@Inject
+	PayloadParser payloadParser;
+	@Inject
+	@IoExecutor
+	Executor ioExecutor;
+	@Inject
+	EventBus eventBus;
+
+	private CameraView cameraView;
+	private LinearLayout cameraOverlay;
+	private QrCodeView qrCodeView;
+	private View statusView;
+	private TextView status;
+
+	private boolean gotRemotePayload;
+	private volatile boolean gotLocalPayload;
+	private KeyAgreementTask task;
+	private KeyAgreementEventListener listener;
+
+	public static KeyAgreementFragment newInstance(
+			KeyAgreementEventListener listener) {
+		Bundle args = new Bundle();
+		KeyAgreementFragment fragment = new KeyAgreementFragment();
+		fragment.listener = listener;
+		fragment.setArguments(args);
+		return fragment;
+	}
+
+	@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_qr, container,
+				false);
+	}
+
+	@Override
+	public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
+		super.onViewCreated(view, savedInstanceState);
+		cameraView = view.findViewById(R.id.camera_view);
+		cameraOverlay = view.findViewById(R.id.camera_overlay);
+		statusView = view.findViewById(R.id.status_container);
+		status = view.findViewById(R.id.connect_status);
+		qrCodeView = view.findViewById(R.id.qr_code_view);
+		qrCodeView.setFullscreenListener(this);
+	}
+
+	@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);
+		}
+		startListening();
+	}
+
+	@Override
+	public void setFullscreen(boolean fullscreen) {
+		LayoutParams statusParams, qrCodeParams;
+		if (fullscreen) {
+			// Grow the QR code view to fill its parent
+			statusParams = new LayoutParams(0, 0, 0f);
+			qrCodeParams = new LayoutParams(MATCH_PARENT, MATCH_PARENT, 1f);
+		} else {
+			// Shrink the QR code view to fill half its parent
+			if (cameraOverlay.getOrientation() == HORIZONTAL) {
+				statusParams = new LayoutParams(0, MATCH_PARENT, 1f);
+				qrCodeParams = new LayoutParams(0, MATCH_PARENT, 1f);
+			} else {
+				statusParams = new LayoutParams(MATCH_PARENT, 0, 1f);
+				qrCodeParams = new LayoutParams(MATCH_PARENT, 0, 1f);
+			}
+		}
+		statusView.setLayoutParams(statusParams);
+		qrCodeView.setLayoutParams(qrCodeParams);
+		cameraOverlay.invalidate();
+	}
+
+	@Override
+	public void onStop() {
+		super.onStop();
+		stopListening();
+		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();
+	}
+
+	@UiThread
+	private void startListening() {
+		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();
+		});
+	}
+
+	@UiThread
+	private void reset() {
+		// If we've stopped the camera view, restart it
+		if (gotRemotePayload) {
+			try {
+				cameraView.start();
+			} catch (CameraException e) {
+				logCameraExceptionAndFinish(e);
+				return;
+			}
+		}
+		statusView.setVisibility(INVISIBLE);
+		cameraView.setVisibility(VISIBLE);
+		gotRemotePayload = false;
+		gotLocalPayload = false;
+		startListening();
+	}
+
+	@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();
+			cameraView.setVisibility(INVISIBLE);
+			statusView.setVisibility(VISIBLE);
+			status.setText(R.string.connecting_to_device);
+			task.connectAndRunProtocol(remotePayload);
+		} catch (UnsupportedVersionException e) {
+			reset();
+			String msg = getString(R.string.qr_code_unsupported,
+					getString(R.string.app_name));
+			showNextFragment(ErrorFragment.newInstance(msg));
+		} catch (CameraException e) {
+			logCameraExceptionAndFinish(e);
+		} catch (IOException | IllegalArgumentException e) {
+			LOG.log(WARNING, "QR Code Invalid", e);
+			reset();
+			Toast.makeText(getActivity(), R.string.qr_code_invalid,
+					LENGTH_LONG).show();
+		}
+	}
+
+	@Override
+	public void eventOccurred(Event e) {
+		if (e instanceof KeyAgreementListeningEvent) {
+			KeyAgreementListeningEvent event = (KeyAgreementListeningEvent) e;
+			gotLocalPayload = true;
+			setQrCode(event.getLocalPayload());
+		} else if (e instanceof KeyAgreementFailedEvent) {
+			keyAgreementFailed();
+		} else if (e instanceof KeyAgreementWaitingEvent) {
+			keyAgreementWaiting();
+		} else if (e instanceof KeyAgreementStartedEvent) {
+			keyAgreementStarted();
+		} else if (e instanceof KeyAgreementAbortedEvent) {
+			KeyAgreementAbortedEvent event = (KeyAgreementAbortedEvent) e;
+			keyAgreementAborted(event.didRemoteAbort());
+		} else if (e instanceof KeyAgreementFinishedEvent) {
+			keyAgreementFinished(((KeyAgreementFinishedEvent) e).getResult());
+		}
+	}
+
+	private void keyAgreementFailed() {
+		runOnUiThreadUnlessDestroyed(() -> {
+			reset();
+			listener.keyAgreementFailed();
+		});
+	}
+
+	private void keyAgreementWaiting() {
+		runOnUiThreadUnlessDestroyed(
+				() -> status.setText(listener.keyAgreementWaiting()));
+	}
+
+	private void keyAgreementStarted() {
+		runOnUiThreadUnlessDestroyed(() -> {
+			qrCodeView.setVisibility(INVISIBLE);
+			statusView.setVisibility(VISIBLE);
+			status.setText(listener.keyAgreementStarted());
+		});
+	}
+
+	private void keyAgreementAborted(boolean remoteAborted) {
+		runOnUiThreadUnlessDestroyed(() -> {
+			reset();
+			qrCodeView.setVisibility(VISIBLE);
+			statusView.setVisibility(INVISIBLE);
+			status.setText(listener.keyAgreementAborted(remoteAborted));
+		});
+	}
+
+	private void keyAgreementFinished(KeyAgreementResult result) {
+		runOnUiThreadUnlessDestroyed(() -> {
+			statusView.setVisibility(VISIBLE);
+			status.setText(listener.keyAgreementFinished(result));
+		});
+	}
+
+	private void setQrCode(Payload localPayload) {
+		Context context = getContext();
+		if (context == null) return;
+		DisplayMetrics dm = context.getResources().getDisplayMetrics();
+		ioExecutor.execute(() -> {
+			byte[] payloadBytes = payloadEncoder.encode(localPayload);
+			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
+	public void handleResult(Result result) {
+		runOnUiThreadUnlessDestroyed(() -> {
+			LOG.info("Got result from decoder");
+			// Ignore results until the KeyAgreementTask is ready
+			if (!gotLocalPayload) return;
+			if (!gotRemotePayload) qrCodeScanned(result.getText());
+		});
+	}
+
+	@Override
+	protected void finish() {
+		getActivity().getSupportFragmentManager().popBackStack();
+	}
+
+	@NotNullByDefault
+	interface KeyAgreementEventListener {
+
+		@UiThread
+		void keyAgreementFailed();
+
+		// Should return a string to be displayed as status.
+		@UiThread
+		@Nullable
+		String keyAgreementWaiting();
+
+		// Should return a string to be displayed as status.
+		@UiThread
+		@Nullable
+		String keyAgreementStarted();
+
+		// Should return a string to be displayed as status.
+		@UiThread
+		@Nullable
+		String keyAgreementAborted(boolean remoteAborted);
+
+		// Should return a string to be displayed as status.
+		@UiThread
+		@Nullable
+		String keyAgreementFinished(KeyAgreementResult result);
+	}
+}
diff --git a/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/PreviewConsumer.java b/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/PreviewConsumer.java
new file mode 100644
index 0000000000000000000000000000000000000000..41ad494f10fe4caa52f44076e25d2f0e6e706063
--- /dev/null
+++ b/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/PreviewConsumer.java
@@ -0,0 +1,17 @@
+package org.briarproject.repeater.keyagreement;
+
+import android.hardware.Camera;
+import android.support.annotation.UiThread;
+
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+
+@SuppressWarnings("deprecation")
+@NotNullByDefault
+interface PreviewConsumer {
+
+	@UiThread
+	void start(Camera camera, int cameraIndex);
+
+	@UiThread
+	void stop();
+}
diff --git a/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/QrCodeDecoder.java b/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/QrCodeDecoder.java
new file mode 100644
index 0000000000000000000000000000000000000000..05b6cdb57bedf7318e3676bc946603c27a2c0a84
--- /dev/null
+++ b/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/QrCodeDecoder.java
@@ -0,0 +1,148 @@
+package org.briarproject.repeater.keyagreement;
+
+import android.hardware.Camera;
+import android.hardware.Camera.CameraInfo;
+import android.hardware.Camera.PreviewCallback;
+import android.hardware.Camera.Size;
+import android.os.AsyncTask;
+import android.support.annotation.UiThread;
+
+import com.google.zxing.BinaryBitmap;
+import com.google.zxing.LuminanceSource;
+import com.google.zxing.PlanarYUVLuminanceSource;
+import com.google.zxing.Reader;
+import com.google.zxing.ReaderException;
+import com.google.zxing.Result;
+import com.google.zxing.common.HybridBinarizer;
+import com.google.zxing.qrcode.QRCodeReader;
+
+import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
+
+import java.util.logging.Logger;
+
+import static com.google.zxing.DecodeHintType.CHARACTER_SET;
+import static java.util.Collections.singletonMap;
+import static java.util.logging.Level.WARNING;
+
+@SuppressWarnings("deprecation")
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+class QrCodeDecoder implements PreviewConsumer, PreviewCallback {
+
+	private static final Logger LOG =
+			Logger.getLogger(QrCodeDecoder.class.getName());
+
+	private final Reader reader = new QRCodeReader();
+	private final ResultCallback callback;
+
+	private Camera camera = null;
+	private int cameraIndex = 0;
+
+	QrCodeDecoder(ResultCallback callback) {
+		this.callback = callback;
+	}
+
+	@Override
+	public void start(Camera camera, int cameraIndex) {
+		this.camera = camera;
+		this.cameraIndex = cameraIndex;
+		askForPreviewFrame();
+	}
+
+	@Override
+	public void stop() {
+		camera = null;
+		cameraIndex = 0;
+	}
+
+	@UiThread
+	private void askForPreviewFrame() {
+		if (camera != null) camera.setOneShotPreviewCallback(this);
+	}
+
+	@UiThread
+	@Override
+	public void onPreviewFrame(byte[] data, Camera camera) {
+		if (camera == this.camera) {
+			try {
+				Size size = camera.getParameters().getPreviewSize();
+				// The preview should be in NV21 format: width * height bytes of
+				// Y followed by width * height / 2 bytes of interleaved U and V
+				if (data.length == size.width * size.height * 3 / 2) {
+					CameraInfo info = new CameraInfo();
+					Camera.getCameraInfo(cameraIndex, info);
+					new DecoderTask(data, size.width, size.height,
+							info.orientation).execute();
+				} else {
+					// Camera parameters have changed - ask for a new preview
+					LOG.info("Preview size does not match camera parameters");
+					askForPreviewFrame();
+				}
+			} catch (RuntimeException e) {
+				LOG.log(WARNING, "Error getting camera parameters.", e);
+			}
+		} else {
+			LOG.info("Camera has changed, ignoring preview frame");
+		}
+	}
+
+	private class DecoderTask extends AsyncTask<Void, Void, Void> {
+
+		private final byte[] data;
+		private final int width, height, orientation;
+
+		private DecoderTask(byte[] data, int width, int height,
+				int orientation) {
+			this.data = data;
+			this.width = width;
+			this.height = height;
+			this.orientation = orientation;
+		}
+
+		@Override
+		protected Void doInBackground(Void... params) {
+			BinaryBitmap bitmap = binarize(data, width, height, orientation);
+			Result result;
+			try {
+				result = reader.decode(bitmap,
+						singletonMap(CHARACTER_SET, "ISO8859_1"));
+			} catch (ReaderException e) {
+				// No barcode found
+				return null;
+			} catch (RuntimeException e) {
+				LOG.warning("Invalid preview frame");
+				return null;
+			} finally {
+				reader.reset();
+			}
+			callback.handleResult(result);
+			return null;
+		}
+
+		@Override
+		protected void onPostExecute(Void result) {
+			askForPreviewFrame();
+		}
+	}
+
+	private static BinaryBitmap binarize(byte[] data, int width, int height,
+			int orientation) {
+		// Crop to a square at the top (portrait) or left (landscape) of the
+		// screen - this will be faster to decode and should include
+		// everything visible in the viewfinder
+		int crop = Math.min(width, height);
+		int left = orientation >= 180 ? width - crop : 0;
+		int top = orientation >= 180 ? height - crop : 0;
+		LuminanceSource src = new PlanarYUVLuminanceSource(data, width,
+				height, left, top, crop, crop, false);
+		return new BinaryBitmap(new HybridBinarizer(src));
+	}
+
+	@NotNullByDefault
+	interface ResultCallback {
+
+		void handleResult(Result result);
+	}
+}
diff --git a/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/QrCodeUtils.java b/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/QrCodeUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..9d8efbe6d9c1518c64ee9e57330eb8fb3416f6e1
--- /dev/null
+++ b/repeater-android/src/main/java/org/briarproject/repeater/keyagreement/QrCodeUtils.java
@@ -0,0 +1,56 @@
+package org.briarproject.repeater.keyagreement;
+
+import android.graphics.Bitmap;
+import android.util.DisplayMetrics;
+
+import com.google.zxing.WriterException;
+import com.google.zxing.common.BitMatrix;
+import com.google.zxing.qrcode.QRCodeWriter;
+
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+
+import java.util.logging.Logger;
+
+import javax.annotation.Nullable;
+
+import static android.graphics.Bitmap.Config.ARGB_8888;
+import static android.graphics.Color.BLACK;
+import static android.graphics.Color.WHITE;
+import static com.google.zxing.BarcodeFormat.QR_CODE;
+import static java.util.logging.Level.WARNING;
+import static org.briarproject.bramble.util.LogUtils.logException;
+
+@NotNullByDefault
+class QrCodeUtils {
+
+	private static final Logger LOG =
+			Logger.getLogger(QrCodeUtils.class.getName());
+
+	@Nullable
+	static Bitmap createQrCode(DisplayMetrics dm, String input) {
+		int smallestDimen = Math.min(dm.widthPixels, dm.heightPixels);
+		try {
+			// Generate QR code
+			BitMatrix encoded = new QRCodeWriter().encode(input, QR_CODE,
+					smallestDimen, smallestDimen);
+			return renderQrCode(encoded);
+		} catch (WriterException e) {
+			logException(LOG, WARNING, e);
+			return null;
+		}
+	}
+
+	private static Bitmap renderQrCode(BitMatrix matrix) {
+		int width = matrix.getWidth();
+		int height = matrix.getHeight();
+		int[] pixels = new int[width * height];
+		for (int x = 0; x < width; x++) {
+			for (int y = 0; y < height; y++) {
+				pixels[y * width + x] = matrix.get(x, y) ? BLACK : WHITE;
+			}
+		}
+		Bitmap qr = Bitmap.createBitmap(width, height, ARGB_8888);
+		qr.setPixels(pixels, 0, width, 0, 0, width, height);
+		return qr;
+	}
+}
diff --git a/repeater-android/src/main/kotlin/org/briarproject/repeater/activity/ActivityComponent.java b/repeater-android/src/main/kotlin/org/briarproject/repeater/activity/ActivityComponent.java
new file mode 100644
index 0000000000000000000000000000000000000000..8261c9517037e8d4a33bb9230bba90a1bbe7f5de
--- /dev/null
+++ b/repeater-android/src/main/kotlin/org/briarproject/repeater/activity/ActivityComponent.java
@@ -0,0 +1,26 @@
+package org.briarproject.repeater.activity;
+
+import android.app.Activity;
+
+import org.briarproject.repeater.AndroidComponent;
+import org.briarproject.repeater.keyagreement.ContactExchangeActivity;
+import org.briarproject.repeater.keyagreement.IntroFragment;
+import org.briarproject.repeater.keyagreement.KeyAgreementActivity;
+import org.briarproject.repeater.keyagreement.KeyAgreementFragment;
+
+import dagger.Component;
+
+@ActivityScope
+@Component(
+		dependencies = AndroidComponent.class)
+public interface ActivityComponent {
+
+	void inject(NavDrawerActivity activity);
+	void inject(ContactExchangeActivity activity);
+	void inject(KeyAgreementActivity activity);
+
+	// Fragments
+	void inject(IntroFragment fragment);
+	void inject(KeyAgreementFragment fragment);
+
+}
diff --git a/repeater-android/src/main/kotlin/org/briarproject/repeater/activity/ActivityScope.java b/repeater-android/src/main/kotlin/org/briarproject/repeater/activity/ActivityScope.java
new file mode 100644
index 0000000000000000000000000000000000000000..c5429cb433bb305e93f08d382260005d6f02b8b8
--- /dev/null
+++ b/repeater-android/src/main/kotlin/org/briarproject/repeater/activity/ActivityScope.java
@@ -0,0 +1,11 @@
+package org.briarproject.repeater.activity;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+import javax.inject.Scope;
+
+@Scope
+@Retention(RetentionPolicy.RUNTIME)
+public @interface ActivityScope {
+}
diff --git a/repeater-android/src/main/kotlin/org/briarproject/repeater/activity/BaseActivity.java b/repeater-android/src/main/kotlin/org/briarproject/repeater/activity/BaseActivity.java
new file mode 100644
index 0000000000000000000000000000000000000000..f61eeb118cc508516fde7bea43555dd5300f0bdd
--- /dev/null
+++ b/repeater-android/src/main/kotlin/org/briarproject/repeater/activity/BaseActivity.java
@@ -0,0 +1,99 @@
+package org.briarproject.repeater.activity;
+
+import android.os.Bundle;
+import android.os.IBinder;
+import android.support.annotation.UiThread;
+import android.support.v7.app.AppCompatActivity;
+import android.support.v7.widget.Toolbar;
+import android.view.View;
+import android.view.inputmethod.InputMethodManager;
+
+import org.briarproject.bramble.api.db.DbException;
+import org.briarproject.briar.repeater.R;
+import org.briarproject.repeater.AndroidComponent;
+import org.briarproject.repeater.RepeaterApplication;
+
+import javax.annotation.Nullable;
+
+import org.briarproject.repeater.fragment.BaseFragment;
+
+import static android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT;
+
+public abstract class BaseActivity extends AppCompatActivity
+{
+	private ActivityComponent activityComponent;
+
+	private boolean destroyed = false;
+
+	@Nullable
+	private Toolbar toolbar = null;
+	private boolean searchedForToolbar = false;
+
+	public abstract void injectActivity(ActivityComponent component);
+
+	@Override
+	public void onCreate(@Nullable Bundle state) {
+		super.onCreate(state);
+
+
+		AndroidComponent applicationComponent =
+				((RepeaterApplication) getApplication()).getApplicationComponent();
+
+		activityComponent = DaggerActivityComponent.builder()
+				.androidComponent(applicationComponent)
+				.build();
+
+		injectActivity(activityComponent);
+
+	}
+
+	public ActivityComponent getActivityComponent() {
+		return activityComponent;
+	}
+
+
+	protected void showInitialFragment(BaseFragment f) {
+		getSupportFragmentManager().beginTransaction()
+				.replace(R.id.fragmentContainer, f, f.getUniqueTag())
+				.commit();
+	}
+
+	public void showNextFragment(BaseFragment f) {
+		getSupportFragmentManager().beginTransaction()
+				.setCustomAnimations(R.anim.step_next_in,
+						R.anim.step_previous_out, R.anim.step_previous_in,
+						R.anim.step_next_out)
+				.replace(R.id.fragmentContainer, f, f.getUniqueTag())
+				.addToBackStack(f.getUniqueTag())
+				.commit();
+	}
+
+
+	@Override
+	protected void onDestroy() {
+		super.onDestroy();
+		destroyed = true;
+	}
+
+	public void runOnUiThreadUnlessDestroyed(Runnable r) {
+		runOnUiThread(() -> {
+			if (!destroyed && !isFinishing()) r.run();
+		});
+	}
+
+	public void showSoftKeyboard(View view) {
+		Object o = getSystemService(INPUT_METHOD_SERVICE);
+		((InputMethodManager) o).showSoftInput(view, SHOW_IMPLICIT);
+	}
+
+	public void hideSoftKeyboard(View view) {
+		IBinder token = view.getWindowToken();
+		Object o = getSystemService(INPUT_METHOD_SERVICE);
+		((InputMethodManager) o).hideSoftInputFromWindow(token, 0);
+	}
+
+	@UiThread
+	public void handleDbException(DbException e) {
+		supportFinishAfterTransition();
+	}
+}
diff --git a/repeater-android/src/main/kotlin/org/briarproject/repeater/activity/NavDrawerActivity.kt b/repeater-android/src/main/kotlin/org/briarproject/repeater/activity/NavDrawerActivity.kt
index 6427d62109410b092c25b878930c02c23d7b3fb0..d2a0da4db8e2f9b63e03a8d2e3af2d2fe1b3acd1 100644
--- a/repeater-android/src/main/kotlin/org/briarproject/repeater/activity/NavDrawerActivity.kt
+++ b/repeater-android/src/main/kotlin/org/briarproject/repeater/activity/NavDrawerActivity.kt
@@ -7,7 +7,6 @@ import android.os.Bundle
 import android.support.design.widget.NavigationView
 import android.support.v4.view.GravityCompat.START
 import android.support.v7.app.ActionBarDrawerToggle
-import android.support.v7.app.AppCompatActivity
 import android.view.Menu
 import android.view.MenuItem
 import kotlinx.android.synthetic.main.activity_nav_drawer.*
@@ -18,8 +17,12 @@ import org.briarproject.repeater.RepeaterService
 import org.briarproject.repeater.fragment.BaseFragment
 import org.briarproject.repeater.fragment.OverviewFragment
 import org.briarproject.repeater.fragment.UserListFragment
+import org.briarproject.repeater.keyagreement.ContactExchangeActivity
 
-class NavDrawerActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener {
+class NavDrawerActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedListener, BaseFragment.BaseFragmentListener {
+
+    override fun injectActivity(component: ActivityComponent?) {
+    }
 
     private lateinit var drawerToggle: ActionBarDrawerToggle
 
@@ -70,7 +73,8 @@ class NavDrawerActivity : AppCompatActivity(), NavigationView.OnNavigationItemSe
     override fun onOptionsItemSelected(item: MenuItem?): Boolean {
         return when (item?.itemId) {
             R.id.action_add_repeater -> {
-                // TODO: add repeater screen
+                val intent = Intent(this, ContactExchangeActivity::class.java)
+                startActivity(intent)
                 true
             }
             else ->
@@ -102,9 +106,9 @@ class NavDrawerActivity : AppCompatActivity(), NavigationView.OnNavigationItemSe
                         R.animator.fade_out, R.animator.fade_in,
                         R.animator.fade_out)
                 .replace(R.id.fragmentContainer, fragment,
-                        fragment.getUniqueTag())
+                        fragment.uniqueTag)
         if (isAddedToBackStack) {
-            trans.addToBackStack(fragment.getUniqueTag())
+            trans.addToBackStack(fragment.uniqueTag)
         }
         trans.commit()
     }
diff --git a/repeater-android/src/main/kotlin/org/briarproject/repeater/activity/SetupActivity.java b/repeater-android/src/main/kotlin/org/briarproject/repeater/activity/SetupActivity.java
new file mode 100644
index 0000000000000000000000000000000000000000..330624888ccdd9681507666e1ca2cf68e32dcb0a
--- /dev/null
+++ b/repeater-android/src/main/kotlin/org/briarproject/repeater/activity/SetupActivity.java
@@ -0,0 +1,4 @@
+package org.briarproject.repeater.activity;
+
+public class SetupActivity {
+}
diff --git a/repeater-android/src/main/kotlin/org/briarproject/repeater/fragment/BaseFragment.kt b/repeater-android/src/main/kotlin/org/briarproject/repeater/fragment/BaseFragment.kt
index 1ee9ece00f5dd26c71e48c65ea57f91914ef35b3..5234d8fba9062dd786ecd3be681c57117ec4f790 100644
--- a/repeater-android/src/main/kotlin/org/briarproject/repeater/fragment/BaseFragment.kt
+++ b/repeater-android/src/main/kotlin/org/briarproject/repeater/fragment/BaseFragment.kt
@@ -1,8 +1,88 @@
 package org.briarproject.repeater.fragment
 
+import android.content.Context
+import android.os.Bundle
+import android.support.annotation.UiThread
 import android.support.v4.app.Fragment
+import android.view.MenuItem
+
+import org.briarproject.bramble.api.db.DbException
+import org.briarproject.repeater.activity.ActivityComponent
 
 abstract class BaseFragment : Fragment() {
 
-    abstract fun getUniqueTag(): String
-}
\ No newline at end of file
+    protected lateinit var listener: BaseFragmentListener
+
+    abstract val uniqueTag: String
+
+    abstract fun injectFragment(component: ActivityComponent)
+
+    override fun onAttach(context: Context?) {
+        super.onAttach(context)
+        listener = context as BaseFragmentListener
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        // allow for "up" button to act as back button
+        setHasOptionsMenu(true)
+    }
+
+    override fun onActivityCreated(savedInstanceState: Bundle?) {
+        super.onActivityCreated(savedInstanceState)
+        injectFragment(listener.getActivityComponent())
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem?): Boolean {
+        return when (item!!.itemId) {
+            android.R.id.home -> {
+                listener.onBackPressed()
+                true
+            }
+            else -> super.onOptionsItemSelected(item)
+        }
+    }
+
+    @UiThread
+    protected open fun finish() {
+        if (!isDetached)
+            activity!!.supportFinishAfterTransition()
+    }
+
+    interface BaseFragmentListener {
+
+        @UiThread
+        fun getActivityComponent(): ActivityComponent
+
+        @UiThread
+        fun onBackPressed()
+
+        @UiThread
+        fun showNextFragment(f: BaseFragment)
+
+        @UiThread
+        fun handleDbException(e: DbException)
+    }
+
+    protected fun showNextFragment(f: BaseFragment) {
+        listener.showNextFragment(f)
+    }
+
+    @UiThread
+    protected fun handleDbException(e: DbException) {
+        listener.handleDbException(e)
+    }
+
+    fun runOnUiThreadUnlessDestroyed(r: Runnable) {
+        val activity = activity
+        activity?.runOnUiThread {
+            // Note that we don't have to check if the activity has
+            // been destroyed as the Fragment has not been detached yet
+            if (!isDetached && !activity.isFinishing) {
+                r.run()
+            }
+        }
+    }
+
+}
diff --git a/repeater-android/src/main/kotlin/org/briarproject/repeater/fragment/OverviewFragment.kt b/repeater-android/src/main/kotlin/org/briarproject/repeater/fragment/OverviewFragment.kt
index 25a29448ab3a5894d5a6132907951b265236e05b..22510c6d45a41fcc6529bc28c034be68baccff16 100644
--- a/repeater-android/src/main/kotlin/org/briarproject/repeater/fragment/OverviewFragment.kt
+++ b/repeater-android/src/main/kotlin/org/briarproject/repeater/fragment/OverviewFragment.kt
@@ -5,13 +5,13 @@ import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
 import org.briarproject.briar.repeater.R
+import org.briarproject.repeater.activity.ActivityComponent
 
 class OverviewFragment : BaseFragment() {
-    companion object {
-        const val TAG = "OverviewFragment"
-    }
+    override val uniqueTag: String = "OverviewFragment"
 
-    override fun getUniqueTag() = TAG
+    override fun injectFragment(component: ActivityComponent) {
+    }
 
     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
         activity?.title = getString(R.string.overview)
diff --git a/repeater-android/src/main/kotlin/org/briarproject/repeater/fragment/UserListFragment.kt b/repeater-android/src/main/kotlin/org/briarproject/repeater/fragment/UserListFragment.kt
index 6000f5bc76e5045fb0c7b00ebeea76bd704b42dc..21a483a8b7ee5daa5f6c131dbc18d0fc4d7fd0e3 100644
--- a/repeater-android/src/main/kotlin/org/briarproject/repeater/fragment/UserListFragment.kt
+++ b/repeater-android/src/main/kotlin/org/briarproject/repeater/fragment/UserListFragment.kt
@@ -7,17 +7,17 @@ import android.view.ViewGroup
 import android.widget.ArrayAdapter
 import kotlinx.android.synthetic.main.fragment_userlist.*
 import org.briarproject.briar.repeater.R
+import org.briarproject.repeater.activity.ActivityComponent
 
 class UserListFragment : BaseFragment() {
-    companion object {
-        const val TAG = "UserListFragment"
+    override val uniqueTag: String = "UserListFragment"
+
+    override fun injectFragment(component: ActivityComponent) {
     }
 
     private val users: Array<String> = arrayOf()
     private lateinit var adapter: ArrayAdapter<String>
 
-    override fun getUniqueTag() = TAG
-
     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
         activity?.title = getString(R.string.users)
         return inflater.inflate(R.layout.fragment_userlist, container, false)
diff --git a/repeater-android/src/main/kotlin/org/briarproject/repeater/view/QrCodeView.kt b/repeater-android/src/main/kotlin/org/briarproject/repeater/view/QrCodeView.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0238d44c92a2f5177f478024c92f5e068897eddc
--- /dev/null
+++ b/repeater-android/src/main/kotlin/org/briarproject/repeater/view/QrCodeView.kt
@@ -0,0 +1,59 @@
+package org.briarproject.repeater.view
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.support.annotation.UiThread
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.animation.AlphaAnimation
+import android.widget.FrameLayout
+import android.widget.ImageView
+import kotlinx.android.synthetic.main.qr_code_view.view.*
+
+import org.briarproject.briar.repeater.R
+
+
+class QrCodeView(context: Context,
+                 attrs: AttributeSet?) : FrameLayout(context, attrs) {
+
+    private var fullscreen = false
+    private var listener: FullscreenListener? = null
+
+    init {
+        val inflater = context
+                .getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
+        inflater.inflate(R.layout.qr_code_view, this, true)
+        val fullscreenButton = findViewById<ImageView>(R.id.fullscreen_button)
+        fullscreenButton.setOnClickListener { v ->
+            fullscreen = !fullscreen
+            if (!fullscreen) {
+                fullscreenButton.setImageResource(
+                        R.drawable.ic_fullscreen_black_48dp)
+            } else {
+                fullscreenButton.setImageResource(
+                        R.drawable.ic_fullscreen_exit_black_48dp)
+            }
+            if (listener != null)
+                listener!!.setFullscreen(fullscreen)
+        }
+    }
+
+    @UiThread
+    fun setQrCode(qrCode: Bitmap) {
+        qr_code.setImageBitmap(qrCode)
+        // Simple fade-in animation
+        val anim = AlphaAnimation(0.0f, 1.0f)
+        anim.duration = 200
+        qr_code.startAnimation(anim)
+    }
+
+    @UiThread
+    fun setFullscreenListener(listener: FullscreenListener) {
+        this.listener = listener
+    }
+
+    interface FullscreenListener {
+        fun setFullscreen(fullscreen: Boolean)
+    }
+
+}
diff --git a/repeater-android/src/main/res/drawable/alerts_and_states_error.xml b/repeater-android/src/main/res/drawable/alerts_and_states_error.xml
new file mode 100644
index 0000000000000000000000000000000000000000..02056c85cec5acdaae01308058a264b1c07b4a3f
--- /dev/null
+++ b/repeater-android/src/main/res/drawable/alerts_and_states_error.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="128dp"
+        android:height="128dp"
+        android:viewportHeight="24.0"
+        android:viewportWidth="24.0">
+	<path
+		android:fillColor="#FF000000"
+		android:pathData="M15.73,3L8.27,3L3,8.27v7.46L8.27,21h7.46L21,15.73L21,8.27L15.73,3zM12,17.3c-0.72,0 -1.3,-0.58 -1.3,-1.3 0,-0.72 0.58,-1.3 1.3,-1.3 0.72,0 1.3,0.58 1.3,1.3 0,0.72 -0.58,1.3 -1.3,1.3zM13,13h-2L11,7h2v6z"/>
+</vector>
diff --git a/repeater-android/src/main/res/drawable/border_explanation.xml b/repeater-android/src/main/res/drawable/border_explanation.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d3990cfc614884fbb32356a741afe9fa4d951052
--- /dev/null
+++ b/repeater-android/src/main/res/drawable/border_explanation.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape
+	xmlns:android="http://schemas.android.com/apk/res/android"
+	android:shape="rectangle">
+
+	<solid
+		android:color="@android:color/transparent"/>
+
+	<stroke
+		android:width="2dp"
+		android:color="@color/colorPrimary"/>
+
+</shape>
\ No newline at end of file
diff --git a/repeater-android/src/main/res/drawable/ic_fullscreen_black_48dp.xml b/repeater-android/src/main/res/drawable/ic_fullscreen_black_48dp.xml
new file mode 100644
index 0000000000000000000000000000000000000000..29b26803eb0d29731863f9c99917b0bee8eeba65
--- /dev/null
+++ b/repeater-android/src/main/res/drawable/ic_fullscreen_black_48dp.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="48dp"
+        android:height="48dp"
+        android:viewportHeight="24.0"
+        android:viewportWidth="24.0">
+	<path
+		android:fillColor="#FF000000"
+		android:pathData="M7,14L5,14v5h5v-2L7,17v-3zM5,10h2L7,7h3L10,5L5,5v5zM17,17h-3v2h5v-5h-2v3zM14,5v2h3v3h2L19,5h-5z"/>
+</vector>
diff --git a/repeater-android/src/main/res/drawable/ic_fullscreen_exit_black_48dp.xml b/repeater-android/src/main/res/drawable/ic_fullscreen_exit_black_48dp.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5b62d104c1dba38e6b6b60aba19014442437b9ed
--- /dev/null
+++ b/repeater-android/src/main/res/drawable/ic_fullscreen_exit_black_48dp.xml
@@ -0,0 +1,4 @@
+<vector android:height="48dp" android:viewportHeight="24.0"
+    android:viewportWidth="24.0" android:width="48dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="#FF000000" android:pathData="M5,16h3v3h2v-5L5,14v2zM8,8L5,8v2h5L10,5L8,5v3zM14,19h2v-3h3v-2h-5v5zM16,8L16,5h-2v5h5L19,8h-3z"/>
+</vector>
diff --git a/repeater-android/src/main/res/drawable/qr_code_explanation.xml b/repeater-android/src/main/res/drawable/qr_code_explanation.xml
new file mode 100644
index 0000000000000000000000000000000000000000..157025c222f4979db4f70804cf72b8e199a4e17c
--- /dev/null
+++ b/repeater-android/src/main/res/drawable/qr_code_explanation.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="400dp"
+        android:height="100dp"
+        android:viewportHeight="49.5"
+        android:viewportWidth="194.8">
+	<path
+		android:fillColor="#000000"
+		android:pathData="M30.1 16.5l-9 0 0 -5c0 -2.4 -2 -4.4 -4.4 -4.4L4.4 7.1C2 7.1 0 9.1 0 11.5l0 24.2c0 2.4 2 4.4 4.4 4.4l9 0 0 5c0 2.4 2 4.4 4.4 4.4l12.2 0c2.4 0 4.4 -2 4.4 -4.4l0 -24.2c0.1 -2.4 -1.9 -4.4 -4.3 -4.4zm-27.4 16.1l0 -20.9 15.8 0 0 20.9 -15.8 0zm10.7 4.6l-5.8 0 0 -1.5 5.8 0 0 1.5zm13.5 9.4l-5.8 0 0 -1.5 5.8 0 0 1.5zm5 -4.6l-15.8 0 0 -1.9 0.5 0c2.4 0 4.4 -2 4.4 -4.4l0 -14.6 10.8 0 0 20.9z"/>
+	<path
+		android:fillColor="#000000"
+		android:pathData="M101.2 16.5l-8.3 0 0 -4.4c0 -1.4 -1.2 -2.6 -2.6 -2.6l-3.9 0 -2.1 -2.5 -6.9 0 -2.2 2.5 -3.8 0c-1.4 0 -2.6 1.2 -2.6 2.6l0 13.3c0 1.4 1.2 2.6 2.6 2.6l13.1 0 0 17.2c0 2.4 2 4.4 4.4 4.4l12.2 0c2.4 0 4.4 -2 4.4 -4.4l0 -24.3c0.2 -2.4 -1.8 -4.4 -4.3 -4.4zm-26.4 2.4c0 -3.3 2.7 -6 6 -6 3.3 0 6 2.7 6 6 0 3.3 -2.7 6 -6 6 -3.3 0 -6 -2.7 -6 -6zm23.2 27.7l-5.8 0 0 -1.5 5.8 0 0 1.5zm5 -4.6l-15.8 0 0 -14.1 3.1 0c1.4 0 2.6 -1.2 2.6 -2.6l0 -4.2 10.1 0 0 20.9z"/>
+	<path
+		android:fillColor="#000000"
+		android:pathData="M84.600003 18.9a3.8 3.8 0 0 1 -3.8 3.8 3.8 3.8 0 0 1 -3.8 -3.8 3.8 3.8 0 0 1 3.8 -3.8 3.8 3.8 0 0 1 3.8 3.8z"/>
+	<path
+		android:fillColor="#000000"
+		android:pathData="M175.3 16.5l-9.8 0 0 -5.7c0 -1.4 -1.2 -2.6 -2.6 -2.6l-19.3 0c-1.4 0 -2.6 1.2 -2.6 2.6l0 14.4c0 1.4 1.2 2.6 2.6 2.6l15.1 0 0 17.3c0 2.4 2 4.4 4.4 4.4l12.2 0c2.4 0 4.4 -2 4.4 -4.4l0 -24.2c0.1 -2.4 -1.9 -4.4 -4.4 -4.4zm-12.4 -5.9l-9.6 6 -9.6 -6 19.2 0zm-19.4 14.8l0 -12.3 9.8 6.1 9.8 -6.1 0 12.3 -19.6 0zm28.6 21.2l-5.8 0 0 -1.5 5.8 0 0 1.5zm5 -4.6l-15.8 0 0 -14.2 1.6 0c1.4 0 2.6 -1.2 2.6 -2.6l0 -4.1 11.6 0 0 20.9z"/>
+	<path
+		android:fillColor="#ff0000"
+		android:pathData="M101.4 17.8l2 2 7.4 -7.3 7.3 7.3 2.1 -2 -7.4 -7.4 7.4 -7.3 -2.1 -2.1 -7.3 7.4 -7.4 -7.4 -2 2.1 7.3 7.3z"/>
+	<path
+		android:fillColor="#ff0000"
+		android:pathData="M176 17.8l2.1 2 7.3 -7.3 7.4 7.3 2 -2 -7.3 -7.4 7.3 -7.3 -2 -2.1 -7.4 7.4 -7.3 -7.4 -2.1 2.1 7.3 7.3z"/>
+	<path
+		android:fillColor="#08b124"
+		android:pathData="M35.8 18.8l0 0L52.5 2.1 50.5 0 35.6 14.8 28.5 7.7l-2.1 2.1 9.2 9.1z"/>
+</vector>
\ No newline at end of file
diff --git a/repeater-android/src/main/res/drawable/qr_code_intro.xml b/repeater-android/src/main/res/drawable/qr_code_intro.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6b09db32dd279901f6b64b19a03ecff6bf58beb6
--- /dev/null
+++ b/repeater-android/src/main/res/drawable/qr_code_intro.xml
@@ -0,0 +1,24 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="409dp"
+        android:height="162dp"
+        android:viewportHeight="161.7"
+        android:viewportWidth="409.2">
+	<path
+		android:fillColor="#FF000000"
+		android:pathData="M369.8,157.4l-4.3,-4.3l-7.1,-2.4c-3.9,-1.3 -8.7,-3 -10.7,-3.7l-3.7,-1.3l3.5,-0.2c8.2,-0.4 13,-4 14.3,-10.9c0.8,-4.1 1.1,-17.3 0.8,-33c-0.2,-8.1 -0.2,-15.4 0,-16.3c0.1,-0.9 0.5,-2.4 0.9,-3.4c1.2,-3.5 0.3,-11.9 -1.9,-17.6c-0.3,-0.9 -1.9,-4.2 -3.5,-7.4c-4.2,-8.2 -4.5,-8.9 -4.9,-10.5c-0.5,-1.8 -0.2,-5.4 0.5,-6.8c0.7,-1.3 2.2,-2.9 3.2,-3.5c1.3,-0.7 2.6,0.1 4.7,2.9c3.4,4.5 14,19.4 15.7,22.2c3.7,6 6,11.2 8,18.8c0.7,2.5 1.9,7 2.7,10.1c0.8,3.1 2.7,10.2 4.1,15.8l2.6,10.2l4.6,5.2c2.6,2.9 5.8,6.5 7.2,8c1.4,1.6 2.5,3 2.5,3.2c0,0.3 -34.5,29.3 -34.9,29.3C374.2,161.7 372.2,159.7 369.8,157.4zM275.9,141c-1.3,-0.6 -2.2,-1.4 -2.9,-2.3c-2.1,-2.7 -2,2.4 -1.9,-68.5l0.1,-64l0.7,-1.2c1,-1.9 2,-2.9 3.7,-3.9l1.6,-0.9l37.8,-0.1c42.5,-0.1 39.4,-0.2 42.1,2.2c0.9,0.8 1.8,2 2.2,2.9c0.7,1.6 0.7,1.6 0.8,14.2l0.1,12.6l-1.8,-0.1c-1.4,-0.1 -2.1,0 -3.2,0.5c-2,1 -3.9,2.9 -5.1,5.1l-1,2l0,-12.8l0,-12.8h-33.6h-33.6v51.3v51.3h33.6h33.6l0.1,-34.4c0.1,-33 0.1,-34.4 0.6,-32.9c0.3,0.8 1.8,4 3.4,7c5.5,10.6 5.4,9.9 5.4,47.2c0,27.6 -0.1,30 -1.7,33.1c-1.1,2.2 -2.7,3.7 -5.1,4.7l-1.7,0.7L314,141.8l-36.2,0.1L275.9,141L275.9,141zM318.3,135.9c2.9,-1.3 4.5,-3.7 4.4,-6.6c0,-4.1 -3.1,-7.2 -7.1,-7.2c-2.1,0 -3.6,0.6 -5.2,2.2c-2.2,2.2 -2.8,5.4 -1.3,8.3c0.7,1.4 2.5,3 4,3.5C314.6,136.6 317,136.6 318.3,135.9z"/>
+	<path
+		android:fillColor="#FF000000"
+		android:pathData="M39.4,157.4l4.3,-4.3l7.1,-2.4c3.9,-1.3 8.7,-3 10.7,-3.7l3.7,-1.3l-3.5,-0.2c-8.2,-0.4 -13,-4 -14.3,-10.9c-0.8,-4.1 -1.1,-17.3 -0.8,-33c0.2,-8.1 0.2,-15.4 0,-16.3c-0.1,-0.9 -0.5,-2.4 -0.9,-3.4c-1.2,-3.5 -0.3,-11.9 1.9,-17.6c0.3,-0.9 1.9,-4.2 3.5,-7.4c4.2,-8.2 4.5,-8.9 4.9,-10.5c0.5,-1.8 0.2,-5.4 -0.5,-6.8c-0.7,-1.3 -2.2,-2.9 -3.2,-3.5c-1.3,-0.7 -2.6,0.1 -4.7,2.9c-3.4,4.5 -14,19.4 -15.7,22.2c-3.7,6 -6,11.2 -8,18.8c-0.7,2.5 -1.9,7 -2.7,10.1c-0.8,3.1 -2.7,10.2 -4.1,15.8l-2.6,10.2l-4.6,5.2c-2.6,2.9 -5.8,6.5 -7.2,8s-2.5,3 -2.5,3.2c0,0.3 34.5,29.3 34.9,29.3C35,161.7 37.1,159.7 39.4,157.4zM133.3,141c1.3,-0.6 2.2,-1.4 2.9,-2.3c2.1,-2.7 2,2.4 1.9,-68.5l-0.1,-64l-0.7,-1.2c-1,-1.9 -2,-2.9 -3.7,-3.9l-1.6,-0.9l-37.8,-0.1c-42.5,-0.1 -39.4,-0.2 -42.1,2.2c-0.9,0.8 -1.8,2 -2.2,2.9c-0.7,1.6 -0.7,1.6 -0.8,14.2L49,32l1.8,-0.1c1.4,-0.1 2.1,0 3.2,0.5c2,1 3.9,2.9 5.1,5.1l1,2l0,-12.8l0,-12.8h33.6h33.6v51.3v51.3L93.8,116.5L60.2,116.5l-0.1,-34.4c-0.1,-33 -0.1,-34.4 -0.6,-32.9c-0.3,0.8 -1.8,4 -3.4,7c-5.5,10.6 -5.4,9.9 -5.4,47.2c0,27.6 0.1,30 1.7,33.1c1.1,2.2 2.7,3.7 5.1,4.7l1.7,0.7l36.2,0.1l36.2,0.1L133.3,141L133.3,141zM90.9,135.9c-2.9,-1.3 -4.5,-3.7 -4.4,-6.6c0,-4.1 3.1,-7.2 7.1,-7.2c2.1,0 3.6,0.6 5.2,2.2c2.2,2.2 2.8,5.4 1.3,8.3c-0.7,1.4 -2.5,3 -4,3.5C94.6,136.6 92.3,136.6 90.9,135.9z"/>
+	<path
+		android:fillColor="#FF000000"
+		android:pathData="M80.5,63h2.3v2.3h2.3v2.3L73.6,67.6v-2.3h2.3v-4.6h4.6L80.5,63L80.5,63zM110.5,83.8h2.3v-2.3h-2.3L110.5,83.8zM82.8,63h2.3v-2.3h-2.3L82.8,63zM115.1,83.8h2.3v-2.3h-2.3L115.1,83.8zM87.4,86.1L92,86.1L92,83.8h-4.6L87.4,86.1zM108.2,86.1L108.2,83.8h-2.3v2.3L108.2,86.1zM99,86.1h2.3v-4.6L99,81.5L99,86.1zM80.5,56.1v2.3h6.9v-2.3L80.5,56.1zM78.2,58.4v-2.3h-4.6v4.6h2.3v-2.3L78.2,58.4zM85.1,53.8L69,53.8v-16.1h16.1L85.1,53.8zM82.8,40L71.3,40v11.5h11.5L82.8,40zM73.6,81.5h6.9v-6.9h-6.9L73.6,81.5zM96.6,79.1v2.3L99,81.4v-2.3L96.6,79.1zM80.5,42.3h-6.9v6.9h6.9L80.5,42.3zM117.4,37.7L117.4,53.8L101.3,53.8v-16.1L117.4,37.7zM115.1,40L103.6,40v11.5h11.5L115.1,40zM69,69.9h16.1v16.1L69,86L69,69.9zM71.3,83.8h11.5v-11.5L71.3,72.3L71.3,83.8zM71.3,56.1L69,56.1v11.5h2.3L71.3,56.1zM101.3,67.6v2.3h2.3v-2.3L101.3,67.6zM94.3,76.9v-2.3L92,74.6v2.3h-4.6v4.6L92,81.5v2.3h2.3v-4.6h2.3v-2.3L94.3,76.9zM87.4,46.9L92,46.9v-2.3h-4.6L87.4,46.9zM105.9,65.3h4.6v2.3h2.3v-6.9h-2.3v-4.6h-2.3v6.9h-6.9v2.3h2.3v2.3h2.3L105.9,65.3zM108.2,72.2h-2.3v-2.3h-2.3v4.6h-6.9v2.3h4.6v4.6h2.3v2.3h2.3v-4.6h9.2v-2.3h-6.9L108.2,72.2zM108.2,72.2h2.3v-4.6h-2.3L108.2,72.2zM89.7,72.2v-2.3L92,69.9v-2.3h2.3v-2.3h2.3v-4.6h6.9v-4.6h-2.3v2.3L99,58.4v-9.2h-2.3v-4.6L99,44.6v-6.9h-2.3v4.6h-2.3v-4.6h-6.9v4.6h2.3v-2.3L92,40v4.6h2.3v6.9h2.3v2.3h-2.3v4.6L92,58.4L92,53.8h-2.3v-2.3h-2.3v4.6h2.3v2.3h-2.3v6.9h2.3v-4.6L92,60.7v4.6h-2.3v2.3h-2.3v6.9L92,74.5v-2.3L89.7,72.2zM115.1,74.5v-2.3h-4.6v2.3L115.1,74.5zM112.8,42.3h-6.9v6.9h6.9L112.8,42.3zM94.3,72.2L99,72.2v-2.3h-2.3v-2.3h-2.3L94.4,72.2zM99,67.6v-2.3h-2.3v2.3L99,67.6zM112.8,58.4h4.6v-2.3h-4.6L112.8,58.4zM115.1,76.9h2.3v-2.3h-2.3L115.1,76.9zM115.1,63h2.3v-2.3h-2.3L115.1,63zM94.3,51.5L92,51.5v2.3h2.3L94.3,51.5zM94.3,51.5"/>
+	<path
+		android:fillColor="#FF000000"
+		android:pathData="M303.5,63h2.3v2.3h2.3v2.3h-11.5v-2.3h2.3v-4.6h4.6L303.5,63L303.5,63zM333.5,83.8h2.3v-2.3h-2.3L333.5,83.8zM305.8,63h2.3v-2.3h-2.3L305.8,63zM338.1,83.8h2.3v-2.3h-2.3L338.1,83.8zM310.4,86.1h4.6L315,83.8h-4.6L310.4,86.1zM331.2,86.1L331.2,83.8h-2.3v2.3L331.2,86.1zM322,86.1h2.3v-4.6L322,81.5L322,86.1zM303.5,56.1v2.3h6.9v-2.3L303.5,56.1zM301.2,58.4v-2.3h-4.6v4.6h2.3v-2.3L301.2,58.4zM308.1,53.8L292,53.8v-16.1h16.1L308.1,53.8zM305.8,40h-11.5v11.5h11.5L305.8,40zM296.6,81.5h6.9v-6.9h-6.9L296.6,81.5zM319.6,79.1v2.3h2.3v-2.3L319.6,79.1zM303.5,42.3h-6.9v6.9h6.9L303.5,42.3zM340.4,37.7L340.4,53.8h-16.1v-16.1L340.4,37.7zM338.1,40h-11.5v11.5h11.5L338.1,40zM292,69.9h16.1v16.1L292,86L292,69.9zM294.3,83.8h11.5v-11.5h-11.5L294.3,83.8zM294.3,56.1L292,56.1v11.5h2.3L294.3,56.1zM324.3,67.6v2.3h2.3v-2.3L324.3,67.6zM317.3,76.9v-2.3L315,74.6v2.3h-4.6v4.6h4.6v2.3h2.3v-4.6h2.3v-2.3L317.3,76.9zM310.4,46.9h4.6v-2.3h-4.6L310.4,46.9zM328.9,65.3h4.6v2.3h2.3v-6.9h-2.3v-4.6h-2.3v6.9h-6.9v2.3h2.3v2.3h2.3L328.9,65.3zM331.2,72.2h-2.3v-2.3h-2.3v4.6h-6.9v2.3h4.6v4.6h2.3v2.3h2.3v-4.6h9.2v-2.3h-6.9L331.2,72.2zM331.2,72.2h2.3v-4.6h-2.3L331.2,72.2zM312.7,72.2v-2.3h2.3v-2.3h2.3v-2.3h2.3v-4.6h6.9v-4.6h-2.3v2.3L322,58.4v-9.2h-2.3v-4.6h2.3v-6.9h-2.3v4.6h-2.3v-4.6h-6.9v4.6h2.3v-2.3h2.3v4.6h2.3v6.9h2.3v2.3h-2.3v4.6L315,58.4L315,53.8h-2.3v-2.3h-2.3v4.6h2.3v2.3h-2.3v6.9h2.3v-4.6h2.3v4.6h-2.3v2.3h-2.3v6.9h4.6v-2.3L312.7,72.2zM338.1,74.5v-2.3h-4.6v2.3L338.1,74.5zM335.8,42.3h-6.9v6.9h6.9L335.8,42.3zM317.3,72.2h4.6v-2.3h-2.3v-2.3h-2.3L317.3,72.2zM322,67.6v-2.3h-2.3v2.3L322,67.6zM335.8,58.4h4.6v-2.3h-4.6L335.8,58.4zM338.1,76.9h2.3v-2.3h-2.3L338.1,76.9zM338.1,63h2.3v-2.3h-2.3L338.1,63zM317.3,51.5L315,51.5v2.3h2.3L317.3,51.5zM317.3,51.5"/>
+	<path
+		android:fillColor="#FF000000"
+		android:pathData="M179.6,48.9l-20.6,18l20.6,16.7v-5.2L199,78.4v-24.3h-19.3L179.7,48.9z"/>
+	<path
+		android:fillColor="#FF000000"
+		android:pathData="M229.4,83.7l20.6,-18l-20.6,-16.7v5.2L210,54.2v24.3h19.3L229.3,83.7z"/>
+</vector>
diff --git a/repeater-android/src/main/res/layout/activity_fragment_container_toolbar.xml b/repeater-android/src/main/res/layout/activity_fragment_container_toolbar.xml
new file mode 100644
index 0000000000000000000000000000000000000000..442bc6de79f4ecb49910578601cdd48d7b8f6192
--- /dev/null
+++ b/repeater-android/src/main/res/layout/activity_fragment_container_toolbar.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+	xmlns:android="http://schemas.android.com/apk/res/android"
+	xmlns:tools="http://schemas.android.com/tools"
+	android:layout_width="match_parent"
+	android:layout_height="match_parent"
+	android:orientation="vertical"
+	tools:context=".android.keyagreement.KeyAgreementActivity">
+
+	<include layout="@layout/toolbar"/>
+
+	<FrameLayout
+		android:id="@+id/fragmentContainer"
+		xmlns:android="http://schemas.android.com/apk/res/android"
+		android:layout_width="match_parent"
+		android:layout_height="match_parent"/>
+
+</LinearLayout>
diff --git a/repeater-android/src/main/res/layout/fragment_error.xml b/repeater-android/src/main/res/layout/fragment_error.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b705051ccdd57d7d9b980ae534b3b99efc20023b
--- /dev/null
+++ b/repeater-android/src/main/res/layout/fragment_error.xml
@@ -0,0 +1,51 @@
+<?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"
+	xmlns:tools="http://schemas.android.com/tools"
+	android:layout_width="match_parent"
+	android:layout_height="match_parent">
+
+	<android.support.v7.widget.AppCompatImageView
+		android:id="@+id/errorIcon"
+		android:layout_width="128dp"
+		android:layout_height="128dp"
+		android:layout_marginEnd="8dp"
+		android:layout_marginStart="8dp"
+		android:layout_marginTop="8dp"
+		android:src="@drawable/alerts_and_states_error"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toTopOf="parent"
+		app:tint="?attr/colorControlNormal"
+		tools:ignore="ContentDescription"/>
+
+	<TextView
+		android:id="@+id/errorTitle"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:layout_marginEnd="8dp"
+		android:layout_marginStart="8dp"
+		android:layout_marginTop="8dp"
+		android:text="@string/sorry"
+		android:textSize="@dimen/text_size_xlarge"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toBottomOf="@+id/errorIcon"/>
+
+	<TextView
+		android:id="@+id/errorMessage"
+		android:layout_width="0dp"
+		android:layout_height="wrap_content"
+		android:layout_marginEnd="16dp"
+		android:layout_marginLeft="16dp"
+		android:layout_marginRight="16dp"
+		android:layout_marginStart="16dp"
+		android:layout_marginTop="8dp"
+		android:textSize="@dimen/text_size_medium"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toBottomOf="@+id/errorTitle"
+		tools:text="@string/qr_code_unsupported"/>
+
+</android.support.constraint.ConstraintLayout>
diff --git a/repeater-android/src/main/res/layout/fragment_keyagreement_id.xml b/repeater-android/src/main/res/layout/fragment_keyagreement_id.xml
new file mode 100644
index 0000000000000000000000000000000000000000..56901cc59d7c418257a07a34fe5fbe1d45833e29
--- /dev/null
+++ b/repeater-android/src/main/res/layout/fragment_keyagreement_id.xml
@@ -0,0 +1,81 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView
+	android:id="@+id/scrollView"
+	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="wrap_content"
+	android:orientation="vertical">
+
+	<android.support.constraint.ConstraintLayout
+		android:layout_width="match_parent"
+		android:layout_height="wrap_content"
+		android:padding="@dimen/margin_large">
+
+		<ImageView
+			android:id="@+id/diagram"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:adjustViewBounds="true"
+			android:paddingBottom="@dimen/margin_large"
+			android:scaleType="fitCenter"
+			android:src="@drawable/qr_code_intro"
+			android:tint="@color/colorPrimary"
+			app:layout_constraintBottom_toTopOf="@id/explanationImage"
+			app:layout_constraintEnd_toEndOf="parent"
+			app:layout_constraintStart_toStartOf="parent"
+			app:layout_constraintTop_toTopOf="parent"/>
+
+		<ImageView
+			android:id="@+id/explanationImage"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:adjustViewBounds="true"
+			android:paddingLeft="@dimen/margin_large"
+			android:paddingRight="@dimen/margin_large"
+			android:paddingTop="@dimen/margin_large"
+			android:scaleType="fitCenter"
+			android:src="@drawable/qr_code_explanation"
+			app:layout_constraintBottom_toTopOf="@id/explanationText"
+			app:layout_constraintEnd_toEndOf="@id/diagram"
+			app:layout_constraintStart_toStartOf="@id/diagram"
+			app:layout_constraintTop_toBottomOf="@id/diagram"
+			tools:ignore="ContentDescription"/>
+
+		<TextView
+			android:id="@+id/explanationText"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:padding="@dimen/margin_large"
+			android:text="@string/face_to_face"
+			app:layout_constrainedWidth="true"
+			app:layout_constraintEnd_toEndOf="@id/explanationImage"
+			app:layout_constraintStart_toStartOf="@id/explanationImage"
+			app:layout_constraintTop_toBottomOf="@id/explanationImage"/>
+
+		<View
+			android:id="@+id/explanationBorder"
+			android:layout_width="0dp"
+			android:layout_height="0dp"
+			android:background="@drawable/border_explanation"
+			app:layout_constraintBottom_toBottomOf="@id/explanationText"
+			app:layout_constraintEnd_toEndOf="@id/explanationImage"
+			app:layout_constraintStart_toStartOf="@id/explanationImage"
+			app:layout_constraintTop_toTopOf="@id/explanationImage"/>
+
+		<Button
+			android:id="@+id/continueButton"
+			style="@style/BriarButton.Default"
+			android:layout_width="0dp"
+			android:layout_height="wrap_content"
+			android:layout_marginTop="@dimen/margin_medium"
+			android:text="@string/continue_button"
+			app:layout_constraintBottom_toBottomOf="parent"
+			app:layout_constraintEnd_toEndOf="@id/explanationImage"
+			app:layout_constraintStart_toStartOf="@id/explanationImage"
+			app:layout_constraintTop_toBottomOf="@id/explanationText"/>
+
+	</android.support.constraint.ConstraintLayout>
+
+</ScrollView>
\ No newline at end of file
diff --git a/repeater-android/src/main/res/layout/fragment_keyagreement_qr.xml b/repeater-android/src/main/res/layout/fragment_keyagreement_qr.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d1c991c9d154a0f6b74518277b4cbf1ce733452f
--- /dev/null
+++ b/repeater-android/src/main/res/layout/fragment_keyagreement_qr.xml
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="utf-8"?>
+<FrameLayout
+	xmlns:android="http://schemas.android.com/apk/res/android"
+	xmlns:tools="http://schemas.android.com/tools"
+	android:layout_width="match_parent"
+	android:layout_height="match_parent">
+
+	<org.briarproject.repeater.keyagreement.CameraView
+		android:id="@+id/camera_view"
+		android:layout_width="match_parent"
+		android:layout_height="match_parent"/>
+
+	<LinearLayout
+		android:id="@+id/camera_overlay"
+		android:layout_width="match_parent"
+		android:layout_height="match_parent"
+		android:baselineAligned="false"
+		android:orientation="vertical">
+
+		<LinearLayout
+			android:id="@+id/status_container"
+			android:layout_width="match_parent"
+			android:layout_height="0dp"
+			android:layout_weight="1"
+			android:gravity="center"
+			android:orientation="vertical"
+			android:padding="@dimen/margin_medium"
+			android:visibility="invisible"
+			tools:visibility="visible">
+
+			<ProgressBar
+				style="?android:attr/progressBarStyleLarge"
+				android:layout_width="wrap_content"
+				android:layout_height="wrap_content"/>
+
+			<TextView
+				android:id="@+id/connect_status"
+				android:layout_width="match_parent"
+				android:layout_height="wrap_content"
+				android:gravity="center"
+				android:paddingTop="@dimen/margin_large"
+				tools:text="Connection failed"/>
+		</LinearLayout>
+
+		<org.briarproject.repeater.view.QrCodeView
+			android:id="@+id/qr_code_view"
+			android:layout_width="match_parent"
+			android:layout_height="0dp"
+			android:layout_weight="1"
+			android:background="@android:color/white"/>
+	</LinearLayout>
+</FrameLayout>
diff --git a/repeater-android/src/main/res/layout/qr_code_view.xml b/repeater-android/src/main/res/layout/qr_code_view.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9062428d0e6b3456b06b1e91a8e952cd3963e44d
--- /dev/null
+++ b/repeater-android/src/main/res/layout/qr_code_view.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+<merge
+	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"
+	tools:showIn="@layout/fragment_keyagreement_qr">
+
+	<ProgressBar
+		style="?android:attr/progressBarStyleLarge"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:layout_gravity="center"/>
+
+	<android.support.constraint.ConstraintLayout
+		android:layout_width="match_parent"
+		android:layout_height="match_parent">
+
+		<ImageView
+			android:id="@+id/qr_code"
+			android:layout_width="match_parent"
+			android:layout_height="match_parent"
+			android:contentDescription="@string/qr_code"
+			android:scaleType="fitCenter"/>
+
+		<ImageView
+			android:id="@+id/fullscreen_button"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:layout_margin="@dimen/margin_small"
+			android:alpha="0.54"
+			android:background="?selectableItemBackground"
+			android:contentDescription="@string/show_qr_code_fullscreen"
+			android:src="@drawable/ic_fullscreen_black_48dp"
+			app:layout_constraintBottom_toBottomOf="parent"
+			app:layout_constraintEnd_toEndOf="parent"
+			app:layout_constraintRight_toRightOf="parent"/>
+
+	</android.support.constraint.ConstraintLayout>
+</merge>