diff --git a/briar-android/AndroidManifest.xml b/briar-android/AndroidManifest.xml
index cab5c80b94ca3db32355596785e7ee1a249a4138..cb723afa40e7011d2ce44d7eb0810328c5093bc1 100644
--- a/briar-android/AndroidManifest.xml
+++ b/briar-android/AndroidManifest.xml
@@ -14,11 +14,13 @@
 		/>
 
 	<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.READ_LOGS"/>
 	<uses-permission android:name="android.permission.VIBRATE" />
@@ -165,6 +167,16 @@
 				android:value=".android.NavDrawerActivity"
 				/>
 		</activity>
+		<activity
+			android:name=".android.keyagreement.KeyAgreementActivity"
+			android:label="@string/add_contact_title"
+			android:theme="@style/BriarThemeNoActionBar.Default"
+			android:parentActivityName=".android.NavDrawerActivity">
+			<meta-data
+				android:name="android.support.PARENT_ACTIVITY"
+				android:value=".android.NavDrawerActivity"
+				/>
+		</activity>
 		<activity
 			android:name=".android.StartupFailureActivity"
 			android:label="@string/startup_failed_activity_title">
diff --git a/briar-android/build.gradle b/briar-android/build.gradle
index 81228e91863e36d784813798f1c84c57615a3254..7e79c0dffb8beccb996572f080d61001d71f22e5 100644
--- a/briar-android/build.gradle
+++ b/briar-android/build.gradle
@@ -46,7 +46,7 @@ dependencyVerification {
 			'com.android.support:recyclerview-v7:7606373da0931a1e62588335465a0e390cd676c98117edab29220317495faefd',
 			'info.guardianproject.panic:panic:a7ed9439826db2e9901649892cf9afbe76f00991b768d8f4c26332d7c9406cb2',
 			'info.guardianproject.trustedintents:trustedintents:6221456d8821a8d974c2acf86306900237cf6afaaa94a4c9c44e161350f80f3e',
-			'com.android.support:support-annotations:f347a35b9748a4103b39a6714a77e2100f488d623fd6268e259c177b200e9d82'
+			'de.hdodenhof:circleimageview:c76d936395b50705a3f98c9220c22d2599aeb9e609f559f6048975cfc1f686b8',
 	]
 }
 
@@ -97,4 +97,4 @@ android {
 	lintOptions {
 		abortOnError false
 	}
-}
\ No newline at end of file
+}
diff --git a/briar-android/res/layout/fragment_keyagreement_qr.xml b/briar-android/res/layout/fragment_keyagreement_qr.xml
new file mode 100644
index 0000000000000000000000000000000000000000..31f15925b70a355c35019d28c9cd81b35d81be5d
--- /dev/null
+++ b/briar-android/res/layout/fragment_keyagreement_qr.xml
@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+	android:id="@+id/qr_layout"
+	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"
+	android:weightSum="2">
+
+	<FrameLayout
+		android:layout_width="match_parent"
+		android:layout_height="0dp"
+		android:layout_weight="1"
+		android:background="@android:color/black"
+		android:gravity="center">
+
+		<RelativeLayout
+			android:layout_width="match_parent"
+			android:layout_height="match_parent"
+			android:background="@android:color/background_light">
+
+			<LinearLayout
+				android:layout_width="match_parent"
+				android:layout_height="wrap_content"
+				android:layout_centerInParent="true"
+				android:gravity="center"
+				android:orientation="vertical"
+				android:padding="@dimen/margin_medium">
+
+				<ProgressBar
+					style="?android:attr/progressBarStyleInverse"
+					android:layout_width="wrap_content"
+					android:layout_height="wrap_content"
+					android:paddingTop="@dimen/margin_large"/>
+
+				<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>
+		</RelativeLayout>
+
+		<org.briarproject.android.util.CameraView
+			android:id="@+id/camera_view"
+			android:layout_width="match_parent"
+			android:layout_height="match_parent"/>
+	</FrameLayout>
+
+	<ImageView
+		android:id="@+id/qr_code"
+		android:layout_width="match_parent"
+		android:layout_height="0dp"
+		android:layout_weight="1"
+		android:scaleType="fitCenter"/>
+</LinearLayout>
\ No newline at end of file
diff --git a/briar-android/res/values/strings.xml b/briar-android/res/values/strings.xml
index cf63cf76bbcfb443dd4c21583f4121cfd6685ed5..f75d8a90d2a549e18c3bbd8fc1bd0938867c8f70 100644
--- a/briar-android/res/values/strings.xml
+++ b/briar-android/res/values/strings.xml
@@ -34,6 +34,7 @@
 	<string name="contact_list_title">Contacts</string>
 	<string name="no_contacts">No contacts</string>
 	<string name="add_contact_title">Add a Contact</string>
+	<string name="add_contact_title_step">Add a Contact - Step %1$d/%2$d</string>
 	<string name="your_nickname">Choose the identity you want to use:</string>
 	<string name="face_to_face">You must be face-to-face with the person you want to add as a contact. This will prevent anyone from impersonating you or reading your messages in future.</string>
 	<string name="continue_button">Continue</string>
@@ -52,6 +53,14 @@
 	<string name="codes_do_not_match">Codes do not match</string>
 	<string name="interfering">This could mean that someone is trying to interfere with your connection</string>
 	<string name="contact_added_toast">Contact added: %s</string>
+	<string name="contact_already_exists">Contact %s already exists</string>
+	<string name="contact_exchange_failed">Contact exchange failed</string>
+	<string name="scan_qr_code">Scan QR code</string>
+	<string name="qr_code_invalid">The QR code is invalid</string>
+	<string name="connecting_to_device">Connecting to device\u2026</string>
+	<string name="authenticating_with_device">Authenticating with device\u2026</string>
+	<string name="connection_aborted_local">Connection aborted by us! This could mean that someone is trying to interfere with your connection</string>
+	<string name="connection_aborted_remote">Connection aborted by your contact! This could mean that someone is trying to interfere with your connection</string>
 	<string name="no_private_messages">No messages</string>
 	<string name="private_message_hint">Type message</string>
 	<string name="message_sent_toast">Message sent</string>
diff --git a/briar-android/src/org/briarproject/android/AndroidComponent.java b/briar-android/src/org/briarproject/android/AndroidComponent.java
index 4db188d9f60f2fa9618ec5333aada3a0c4f44542..a00d6fb1293179d1ed9f9282416f6f7e8e7494b2 100644
--- a/briar-android/src/org/briarproject/android/AndroidComponent.java
+++ b/briar-android/src/org/briarproject/android/AndroidComponent.java
@@ -13,6 +13,9 @@ import org.briarproject.android.forum.ShareForumActivity;
 import org.briarproject.android.forum.WriteForumPostActivity;
 import org.briarproject.android.identity.CreateIdentityActivity;
 import org.briarproject.android.invitation.AddContactActivity;
+import org.briarproject.android.keyagreement.ChooseIdentityFragment;
+import org.briarproject.android.keyagreement.KeyAgreementActivity;
+import org.briarproject.android.keyagreement.ShowQrCodeFragment;
 import org.briarproject.android.panic.PanicPreferencesActivity;
 import org.briarproject.android.panic.PanicResponderActivity;
 import org.briarproject.plugins.AndroidPluginsModule;
@@ -44,6 +47,8 @@ public interface AndroidComponent extends CoreEagerSingletons {
 
 	void inject(AddContactActivity activity);
 
+	void inject(KeyAgreementActivity activity);
+
 	void inject(ConversationActivity activity);
 
 	void inject(CreateIdentityActivity activity);
@@ -68,6 +73,10 @@ public interface AndroidComponent extends CoreEagerSingletons {
 
 	void inject(ForumListFragment fragment);
 
+	void inject(ChooseIdentityFragment fragment);
+
+	void inject(ShowQrCodeFragment fragment);
+
 	// Eager singleton load
 	void inject(AndroidModule.EagerSingletons init);
 
diff --git a/briar-android/src/org/briarproject/android/BriarFragmentActivity.java b/briar-android/src/org/briarproject/android/BriarFragmentActivity.java
index 7b1e6ff535aa49cca6fc6778d06bd705daf62229..6bef438cf98803912918baaee7c8ab2ea79e916a 100644
--- a/briar-android/src/org/briarproject/android/BriarFragmentActivity.java
+++ b/briar-android/src/org/briarproject/android/BriarFragmentActivity.java
@@ -42,7 +42,8 @@ public abstract class BriarFragmentActivity extends BriarActivity {
 
 	@Override
 	public void onBackPressed() {
-		if (getSupportFragmentManager().getBackStackEntryCount() == 0 &&
+		if (this instanceof NavDrawerActivity &&
+				getSupportFragmentManager().getBackStackEntryCount() == 0 &&
 				getSupportFragmentManager()
 						.findFragmentByTag(ContactListFragment.TAG) == null) {
 			/*
diff --git a/briar-android/src/org/briarproject/android/keyagreement/ChooseIdentityFragment.java b/briar-android/src/org/briarproject/android/keyagreement/ChooseIdentityFragment.java
new file mode 100644
index 0000000000000000000000000000000000000000..4615cd728625345b440d55e4e4fa8695d3f92e38
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/keyagreement/ChooseIdentityFragment.java
@@ -0,0 +1,193 @@
+package org.briarproject.android.keyagreement;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemSelectedListener;
+import android.widget.Spinner;
+
+import org.briarproject.R;
+import org.briarproject.android.AndroidComponent;
+import org.briarproject.android.fragment.BaseFragment;
+import org.briarproject.android.identity.CreateIdentityActivity;
+import org.briarproject.android.identity.LocalAuthorItem;
+import org.briarproject.android.identity.LocalAuthorItemComparator;
+import org.briarproject.android.identity.LocalAuthorSpinnerAdapter;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.identity.AuthorId;
+import org.briarproject.api.identity.IdentityManager;
+import org.briarproject.api.identity.LocalAuthor;
+
+import java.util.Collection;
+import java.util.logging.Logger;
+
+import javax.inject.Inject;
+
+import static android.app.Activity.RESULT_OK;
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+import static org.briarproject.android.identity.LocalAuthorItem.NEW;
+
+public class ChooseIdentityFragment extends BaseFragment
+		implements OnItemSelectedListener {
+
+	interface IdentitySelectedListener {
+		void identitySelected(AuthorId localAuthorId);
+	}
+
+	private static final Logger LOG =
+			Logger.getLogger(ChooseIdentityFragment.class.getName());
+
+	public static final String TAG = "ChooseIdentityFragment";
+
+	private static final int REQUEST_CREATE_IDENTITY = 1;
+
+	private IdentitySelectedListener lsnr;
+	private LocalAuthorSpinnerAdapter adapter;
+	private Spinner spinner;
+	private View button;
+
+	private AuthorId localAuthorId;
+
+	// Fields that are accessed from background threads must be volatile
+	@Inject
+	protected volatile IdentityManager identityManager;
+
+	public static ChooseIdentityFragment newInstance() {
+		Bundle args = new Bundle();
+		ChooseIdentityFragment fragment = new ChooseIdentityFragment();
+		fragment.setArguments(args);
+		return fragment;
+	}
+
+	@Override
+	public void onAttach(Context context) {
+		super.onAttach(context);
+		try {
+			lsnr = (IdentitySelectedListener) context;
+		} catch (ClassCastException e) {
+			throw new ClassCastException(
+					"Using class must implement IdentitySelectedListener");
+		}
+	}
+
+	@Override
+	public String getUniqueTag() {
+		return TAG;
+	}
+
+	@Override
+	public void injectActivity(AndroidComponent component) {
+		component.inject(this);
+	}
+
+	@Nullable
+	@Override
+	public View onCreateView(LayoutInflater inflater, ViewGroup container,
+			Bundle savedInstanceState) {
+		return inflater.inflate(R.layout.invitation_bluetooth_start, container,
+				false);
+	}
+
+	@Override
+	public void onViewCreated(View view, Bundle savedInstanceState) {
+		super.onViewCreated(view, savedInstanceState);
+
+		adapter = new LocalAuthorSpinnerAdapter(getActivity(), false);
+		spinner = (Spinner) view.findViewById(R.id.spinner);
+		spinner.setAdapter(adapter);
+		spinner.setOnItemSelectedListener(this);
+
+		button = view.findViewById(R.id.continueButton);
+		button.setEnabled(false);
+		button.setOnClickListener(
+				new OnClickListener() {
+					@Override
+					public void onClick(View view) {
+						lsnr.identitySelected(localAuthorId);
+					}
+				});
+
+		loadLocalAuthors();
+	}
+
+	private void loadLocalAuthors() {
+		listener.runOnDbThread(new Runnable() {
+			public void run() {
+				try {
+					long now = System.currentTimeMillis();
+					Collection<LocalAuthor> authors =
+							identityManager.getLocalAuthors();
+					long duration = System.currentTimeMillis() - now;
+					if (LOG.isLoggable(INFO))
+						LOG.info("Loading authors took " + duration + " ms");
+					displayLocalAuthors(authors);
+				} catch (DbException e) {
+					if (LOG.isLoggable(WARNING))
+						LOG.log(WARNING, e.toString(), e);
+				}
+			}
+		});
+	}
+
+	private void displayLocalAuthors(final Collection<LocalAuthor> authors) {
+		listener.runOnUiThread(new Runnable() {
+			@Override
+			public void run() {
+				adapter.clear();
+				for (LocalAuthor a : authors)
+					adapter.add(new LocalAuthorItem(a));
+				adapter.sort(LocalAuthorItemComparator.INSTANCE);
+				// If a local author has been selected, select it again
+				if (localAuthorId == null) return;
+				int count = adapter.getCount();
+				for (int i = 0; i < count; i++) {
+					LocalAuthorItem item = adapter.getItem(i);
+					if (item == NEW) continue;
+					if (item.getLocalAuthor().getId().equals(localAuthorId)) {
+						spinner.setSelection(i);
+						return;
+					}
+				}
+			}
+		});
+	}
+
+	private void setLocalAuthorId(AuthorId authorId) {
+		localAuthorId = authorId;
+		button.setEnabled(localAuthorId != null);
+	}
+
+	public void onItemSelected(AdapterView<?> parent, View view, int position,
+			long id) {
+		LocalAuthorItem item = adapter.getItem(position);
+		if (item == NEW) {
+			setLocalAuthorId(null);
+			Intent i = new Intent(getActivity(), CreateIdentityActivity.class);
+			startActivityForResult(i, REQUEST_CREATE_IDENTITY);
+		} else {
+			setLocalAuthorId(item.getLocalAuthor().getId());
+		}
+	}
+
+	public void onNothingSelected(AdapterView<?> parent) {
+		setLocalAuthorId(null);
+	}
+
+	@Override
+	public void onActivityResult(int request, int result, Intent data) {
+		if (request == REQUEST_CREATE_IDENTITY && result == RESULT_OK) {
+			byte[] b = data.getByteArrayExtra("briar.LOCAL_AUTHOR_ID");
+			if (b == null) throw new IllegalStateException();
+			setLocalAuthorId(new AuthorId(b));
+			loadLocalAuthors();
+		} else
+			super.onActivityResult(request, result, data);
+	}
+}
diff --git a/briar-android/src/org/briarproject/android/keyagreement/KeyAgreementActivity.java b/briar-android/src/org/briarproject/android/keyagreement/KeyAgreementActivity.java
new file mode 100644
index 0000000000000000000000000000000000000000..516e4c6e5a8e58c5bc751bac92c6891cf2a110e7
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/keyagreement/KeyAgreementActivity.java
@@ -0,0 +1,230 @@
+package org.briarproject.android.keyagreement;
+
+import android.os.Bundle;
+import android.support.v7.widget.Toolbar;
+import android.view.View;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import org.briarproject.R;
+import org.briarproject.android.AndroidComponent;
+import org.briarproject.android.BriarFragmentActivity;
+import org.briarproject.android.fragment.BaseFragment;
+import org.briarproject.android.util.CustomAnimations;
+import org.briarproject.api.contact.ContactExchangeListener;
+import org.briarproject.api.contact.ContactExchangeTask;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.event.Event;
+import org.briarproject.api.event.EventBus;
+import org.briarproject.api.event.EventListener;
+import org.briarproject.api.event.KeyAgreementFinishedEvent;
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.identity.AuthorId;
+import org.briarproject.api.identity.IdentityManager;
+import org.briarproject.api.identity.LocalAuthor;
+import org.briarproject.api.keyagreement.KeyAgreementResult;
+import org.briarproject.api.settings.SettingsManager;
+
+import java.util.logging.Logger;
+
+import javax.inject.Inject;
+
+import static android.widget.Toast.LENGTH_LONG;
+import static java.util.logging.Level.WARNING;
+
+public class KeyAgreementActivity extends BriarFragmentActivity implements
+		BaseFragment.BaseFragmentListener,
+		ChooseIdentityFragment.IdentitySelectedListener, EventListener,
+		ContactExchangeListener {
+
+	private static final Logger LOG =
+			Logger.getLogger(KeyAgreementActivity.class.getName());
+
+	private static final String LOCAL_AUTHOR_ID = "briar.LOCAL_AUTHOR_ID";
+
+	private static final int STEP_ID = 1;
+	private static final int STEP_QR = 2;
+	private static final int STEPS = 2;
+
+	@Inject
+	protected EventBus eventBus;
+	@Inject
+	protected SettingsManager settingsManager;
+
+	private Toolbar toolbar;
+	private View progressContainer;
+	private TextView progressTitle;
+
+	private AuthorId localAuthorId;
+
+	@Inject
+	protected volatile ContactExchangeTask contactExchangeTask;
+	@Inject
+	protected volatile IdentityManager identityManager;
+
+	@Override
+	public void injectActivity(AndroidComponent component) {
+		component.inject(this);
+	}
+
+	@SuppressWarnings("ConstantConditions")
+	@Override
+	public void onCreate(Bundle state) {
+		super.onCreate(state);
+		setContentView(R.layout.activity_with_loading);
+
+		toolbar = (Toolbar) findViewById(R.id.toolbar);
+		progressContainer = findViewById(R.id.container_progress);
+		progressTitle = (TextView) findViewById(R.id.title_progress_bar);
+
+		setSupportActionBar(toolbar);
+		getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+
+		if (state != null) {
+			byte[] b = state.getByteArray(LOCAL_AUTHOR_ID);
+			if (b != null)
+				localAuthorId = new AuthorId(b);
+		}
+
+		showStep(localAuthorId == null ? STEP_ID : STEP_QR);
+	}
+
+	@SuppressWarnings("ConstantConditions")
+	private void showStep(int step) {
+		getSupportActionBar().setTitle(
+				String.format(getString(R.string.add_contact_title_step), step,
+						STEPS));
+		switch (step) {
+			case STEP_QR:
+				startFragment(ShowQrCodeFragment.newInstance());
+				break;
+			case STEP_ID:
+			default:
+				startFragment(ChooseIdentityFragment.newInstance());
+				break;
+		}
+	}
+
+	@Override
+	public void onResume() {
+		super.onResume();
+		eventBus.addListener(this);
+	}
+
+	@Override
+	protected void onPause() {
+		super.onPause();
+		eventBus.removeListener(this);
+	}
+
+	@Override
+	public void onSaveInstanceState(Bundle state) {
+		super.onSaveInstanceState(state);
+		if (localAuthorId != null) {
+			byte[] b = localAuthorId.getBytes();
+			state.putByteArray(LOCAL_AUTHOR_ID, b);
+		}
+	}
+
+	@Override
+	public void showLoadingScreen(boolean isBlocking, int stringId) {
+		if (isBlocking) {
+			CustomAnimations.animateHeight(toolbar, false, 250);
+		}
+		progressTitle.setText(stringId);
+		progressContainer.setVisibility(View.VISIBLE);
+	}
+
+	@Override
+	public void hideLoadingScreen() {
+		CustomAnimations.animateHeight(toolbar, true, 250);
+		progressContainer.setVisibility(View.INVISIBLE);
+	}
+
+	@Override
+	public void identitySelected(AuthorId localAuthorId) {
+		this.localAuthorId = localAuthorId;
+		showStep(STEP_QR);
+	}
+
+	@Override
+	public void eventOccurred(Event e) {
+		if (e instanceof KeyAgreementFinishedEvent) {
+			KeyAgreementFinishedEvent event = (KeyAgreementFinishedEvent) e;
+			keyAgreementFinished(event.getResult());
+		}
+	}
+
+	private void keyAgreementFinished(final KeyAgreementResult result) {
+		runOnUiThread(new Runnable() {
+			@Override
+			public void run() {
+				showLoadingScreen(false, R.string.exchanging_contact_details);
+				startContactExchange(result);
+			}
+		});
+	}
+
+	private void startContactExchange(final KeyAgreementResult result) {
+		runOnDbThread(new Runnable() {
+			@Override
+			public void run() {
+				LocalAuthor localAuthor;
+				// Load the local pseudonym
+				try {
+					localAuthor = identityManager.getLocalAuthor(localAuthorId);
+				} catch (DbException e) {
+					if (LOG.isLoggable(WARNING))
+						LOG.log(WARNING, e.toString(), e);
+					contactExchangeFailed();
+					return;
+				}
+
+				// Exchange contact details
+				contactExchangeTask.startExchange(KeyAgreementActivity.this,
+						localAuthor, result.getMasterKey(),
+						result.getConnection(), result.getTransportId(),
+						result.wasAlice(), true);
+			}
+		});
+	}
+
+	@Override
+	public void contactExchangeSucceeded(final Author remoteAuthor) {
+		runOnUiThread(new Runnable() {
+			public void run() {
+				String contactName = remoteAuthor.getName();
+				String format = getString(R.string.contact_added_toast);
+				String text = String.format(format, contactName);
+				Toast.makeText(KeyAgreementActivity.this, text, LENGTH_LONG)
+						.show();
+				finish();
+			}
+		});
+	}
+
+	@Override
+	public void duplicateContact(final Author remoteAuthor) {
+		runOnUiThread(new Runnable() {
+			public void run() {
+				String contactName = remoteAuthor.getName();
+				String format = getString(R.string.contact_already_exists);
+				String text = String.format(format, contactName);
+				Toast.makeText(KeyAgreementActivity.this, text, LENGTH_LONG)
+						.show();
+				finish();
+			}
+		});
+	}
+
+	@Override
+	public void contactExchangeFailed() {
+		runOnUiThread(new Runnable() {
+			public void run() {
+				Toast.makeText(KeyAgreementActivity.this,
+						R.string.contact_exchange_failed, LENGTH_LONG).show();
+				finish();
+			}
+		});
+	}
+}
diff --git a/briar-android/src/org/briarproject/android/keyagreement/ShowQrCodeFragment.java b/briar-android/src/org/briarproject/android/keyagreement/ShowQrCodeFragment.java
new file mode 100644
index 0000000000000000000000000000000000000000..7845033dfa01ddff738cf84edbd41ff2db54aebb
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/keyagreement/ShowQrCodeFragment.java
@@ -0,0 +1,384 @@
+package org.briarproject.android.keyagreement;
+
+import android.bluetooth.BluetoothAdapter;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.hardware.Camera;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.util.Base64;
+import android.view.Display;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.AlphaAnimation;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.google.zxing.Result;
+
+import org.briarproject.R;
+import org.briarproject.android.AndroidComponent;
+import org.briarproject.android.fragment.BaseEventFragment;
+import org.briarproject.android.util.AndroidUtils;
+import org.briarproject.android.util.CameraView;
+import org.briarproject.android.util.QrCodeUtils;
+import org.briarproject.android.util.QrCodeDecoder;
+import org.briarproject.api.event.Event;
+import org.briarproject.api.event.KeyAgreementAbortedEvent;
+import org.briarproject.api.event.KeyAgreementFailedEvent;
+import org.briarproject.api.event.KeyAgreementFinishedEvent;
+import org.briarproject.api.event.KeyAgreementListeningEvent;
+import org.briarproject.api.event.KeyAgreementStartedEvent;
+import org.briarproject.api.event.KeyAgreementWaitingEvent;
+import org.briarproject.api.keyagreement.KeyAgreementTask;
+import org.briarproject.api.keyagreement.KeyAgreementTaskFactory;
+import org.briarproject.api.keyagreement.Payload;
+import org.briarproject.api.keyagreement.PayloadEncoder;
+import org.briarproject.api.keyagreement.PayloadParser;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.Logger;
+
+import javax.inject.Inject;
+
+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.ActivityInfo.SCREEN_ORIENTATION_NOSENSOR;
+import static android.widget.LinearLayout.HORIZONTAL;
+import static android.widget.LinearLayout.VERTICAL;
+import static android.widget.Toast.LENGTH_LONG;
+import static java.util.logging.Level.WARNING;
+
+public class ShowQrCodeFragment extends BaseEventFragment
+		implements QrCodeDecoder.ResultCallback {
+
+	private static final Logger LOG =
+			Logger.getLogger(ShowQrCodeFragment.class.getName());
+
+	public static final String TAG = "ShowQrCodeFragment";
+
+	@Inject
+	protected KeyAgreementTaskFactory keyAgreementTaskFactory;
+	@Inject
+	protected PayloadEncoder payloadEncoder;
+	@Inject
+	protected PayloadParser payloadParser;
+
+	private LinearLayout qrLayout;
+	private CameraView cameraView;
+	private TextView status;
+	private ImageView qrCode;
+
+	private volatile KeyAgreementTask task;
+	private volatile boolean toggleBluetooth;
+	private volatile BluetoothAdapter adapter;
+	private BluetoothStateReceiver receiver;
+	private AtomicBoolean waitingForBluetooth = new AtomicBoolean();
+	private QrCodeDecoder decoder;
+	private boolean gotRemotePayload;
+
+	public static ShowQrCodeFragment newInstance() {
+		Bundle args = new Bundle();
+		ShowQrCodeFragment fragment = new ShowQrCodeFragment();
+		fragment.setArguments(args);
+		return fragment;
+	}
+
+	@Override
+	public String getUniqueTag() {
+		return TAG;
+	}
+
+	@Override
+	public void injectActivity(AndroidComponent component) {
+		component.inject(this);
+	}
+
+	@Nullable
+	@Override
+	public View onCreateView(LayoutInflater inflater, ViewGroup container,
+			Bundle savedInstanceState) {
+		return inflater.inflate(R.layout.fragment_keyagreement_qr, container,
+				false);
+	}
+
+	@Override
+	public void onViewCreated(View view, Bundle savedInstanceState) {
+		super.onViewCreated(view, savedInstanceState);
+
+		qrLayout = (LinearLayout) view.findViewById(R.id.qr_layout);
+		cameraView = (CameraView) view.findViewById(R.id.camera_view);
+		status = (TextView) view.findViewById(R.id.connect_status);
+		qrCode = (ImageView) view.findViewById(R.id.qr_code);
+	}
+
+	@Override
+	public void onActivityCreated(Bundle savedInstanceState) {
+		super.onActivityCreated(savedInstanceState);
+
+		getActivity().setRequestedOrientation(SCREEN_ORIENTATION_NOSENSOR);
+
+		decoder = new QrCodeDecoder(this);
+
+		Display display = getActivity().getWindowManager().getDefaultDisplay();
+		boolean portrait = display.getWidth() < display.getHeight();
+		qrLayout.setOrientation(portrait ? VERTICAL : HORIZONTAL);
+
+		// Only enable BT adapter if it is not already on.
+		adapter = BluetoothAdapter.getDefaultAdapter();
+		if (adapter != null)
+			toggleBluetooth = !adapter.isEnabled();
+	}
+
+	@Override
+	public void onStart() {
+		super.onStart();
+
+		// Listen for changes to the Bluetooth state
+		IntentFilter filter = new IntentFilter();
+		filter.addAction(ACTION_STATE_CHANGED);
+		receiver = new BluetoothStateReceiver();
+		getActivity().registerReceiver(receiver, filter);
+
+		if (adapter != null && toggleBluetooth) {
+			waitingForBluetooth.set(true);
+			toggleBluetooth(true);
+		} else
+			startListening();
+	}
+
+	@Override
+	public void onResume() {
+		super.onResume();
+		if (!gotRemotePayload) openCamera();
+	}
+
+	@Override
+	public void onPause() {
+		super.onPause();
+		if (!gotRemotePayload) releaseCamera();
+	}
+
+	@Override
+	public void onStop() {
+		super.onStop();
+		stopListening();
+		if (receiver != null) getActivity().unregisterReceiver(receiver);
+	}
+
+	private void startListening() {
+		task = keyAgreementTaskFactory.getTask();
+		gotRemotePayload = false;
+		new Thread(new Runnable() {
+			@Override
+			public void run() {
+				task.listen();
+			}
+		}).start();
+	}
+
+	private void stopListening() {
+		new Thread(new Runnable() {
+			@Override
+			public void run() {
+				task.stopListening();
+				if (toggleBluetooth) toggleBluetooth(false);
+			}
+		}).start();
+	}
+
+	private void toggleBluetooth(boolean enable) {
+		if (adapter != null) {
+			AndroidUtils.enableBluetooth(adapter, enable);
+		}
+	}
+
+	private void openCamera() {
+		AsyncTask<Void, Void, Camera> openTask =
+				new AsyncTask<Void, Void, Camera>() {
+					@Override
+					protected Camera doInBackground(Void... unused) {
+						LOG.info("Opening camera");
+						try {
+							return Camera.open();
+						} catch (RuntimeException e) {
+							LOG.log(WARNING,
+									"Error opening camera, trying again", e);
+							try {
+								Thread.sleep(1000);
+							} catch (InterruptedException e2) {
+								LOG.info("Interrupted before second attempt");
+								return null;
+							}
+							try {
+								return Camera.open();
+							} catch (RuntimeException e2) {
+								LOG.log(WARNING, "Error opening camera", e2);
+								return null;
+							}
+						}
+					}
+
+					@Override
+					protected void onPostExecute(Camera camera) {
+						if (camera == null) {
+							// TODO better solution?
+							getActivity().finish();
+						} else {
+							cameraView.start(camera, decoder, 0);
+						}
+					}
+				};
+		openTask.execute();
+	}
+
+	private void releaseCamera() {
+		LOG.info("Releasing camera");
+		try {
+			cameraView.stop();
+		} catch (RuntimeException e) {
+			LOG.log(WARNING, "Error releasing camera", e);
+			// TODO better solution
+			getActivity().finish();
+		}
+	}
+
+	private void reset() {
+		cameraView.setVisibility(View.VISIBLE);
+		startListening();
+		openCamera();
+	}
+
+	private void qrCodeScanned(String content) {
+		try {
+			// TODO use Base32
+			Payload remotePayload = payloadParser.parse(
+					Base64.decode(content, 0));
+			cameraView.setVisibility(View.GONE);
+			status.setText(R.string.connecting_to_device);
+			task.connectAndRunProtocol(remotePayload);
+		} catch (IOException e) {
+			// TODO show failure
+			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;
+			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) {
+			// We want to reuse the connection, so don't disable Bluetooth
+			toggleBluetooth = false;
+		}
+	}
+
+	private void setQrCode(final Payload localPayload) {
+		listener.runOnUiThread(new Runnable() {
+			@Override
+			public void run() {
+				// TODO use Base32
+				String input = Base64.encodeToString(
+						payloadEncoder.encode(localPayload), 0);
+				qrCode.setImageBitmap(
+						QrCodeUtils.createQrCode(getActivity(), input));
+				// Simple fade-in animation
+				AlphaAnimation anim = new AlphaAnimation(0.0f, 1.0f);
+				anim.setDuration(200);
+				qrCode.startAnimation(anim);
+			}
+		});
+	}
+
+	private void keyAgreementFailed() {
+		listener.runOnUiThread(new Runnable() {
+			@Override
+			public void run() {
+				reset();
+				// TODO show failure somewhere persistent?
+				Toast.makeText(getActivity(), R.string.connection_failed,
+						LENGTH_LONG).show();
+			}
+		});
+	}
+
+	private void keyAgreementWaiting() {
+		listener.runOnUiThread(new Runnable() {
+			@Override
+			public void run() {
+				status.setText(R.string.waiting_for_contact);
+			}
+		});
+	}
+
+	private void keyAgreementStarted() {
+		listener.runOnUiThread(new Runnable() {
+			@Override
+			public void run() {
+				listener.showLoadingScreen(false,
+						R.string.authenticating_with_device);
+			}
+		});
+	}
+
+	private void keyAgreementAborted(final boolean remoteAborted) {
+		listener.runOnUiThread(new Runnable() {
+			@Override
+			public void run() {
+				reset();
+				listener.hideLoadingScreen();
+				// TODO show abort somewhere persistent?
+				Toast.makeText(getActivity(),
+						remoteAborted ? R.string.connection_aborted_remote :
+								R.string.connection_aborted_local, LENGTH_LONG)
+						.show();
+			}
+		});
+	}
+
+	@Override
+	public void handleResult(final Result result) {
+		listener.runOnUiThread(new Runnable() {
+			public void run() {
+				LOG.info("Got result from decoder");
+				if (!gotRemotePayload) {
+					gotRemotePayload = true;
+					releaseCamera();
+					qrCodeScanned(result.getText());
+				}
+			}
+		});
+	}
+
+	private class BluetoothStateReceiver extends BroadcastReceiver {
+
+		@Override
+		public void onReceive(Context ctx, Intent intent) {
+			int state = intent.getIntExtra(EXTRA_STATE, 0);
+			if (state == STATE_ON && waitingForBluetooth.get()) {
+				LOG.info("Bluetooth enabled");
+				waitingForBluetooth.set(false);
+				startListening();
+			}
+		}
+	}
+}
diff --git a/briar-android/src/org/briarproject/android/util/CameraView.java b/briar-android/src/org/briarproject/android/util/CameraView.java
new file mode 100644
index 0000000000000000000000000000000000000000..fcd9320a0705ca969476be2663918e5fa63598bb
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/util/CameraView.java
@@ -0,0 +1,258 @@
+package org.briarproject.android.util;
+
+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.os.Build;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.logging.Logger;
+
+import static android.hardware.Camera.CameraInfo.CAMERA_FACING_FRONT;
+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_BARCODE;
+import static android.view.SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS;
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+
+@SuppressWarnings("deprecation")
+public class CameraView extends SurfaceView implements SurfaceHolder.Callback,
+		AutoFocusCallback {
+
+	private static final int AUTO_FOCUS_RETRY_DELAY = 5000; // Milliseconds
+	private static final Logger LOG =
+			Logger.getLogger(CameraView.class.getName());
+
+	private Camera camera = null;
+	private PreviewConsumer previewConsumer = null;
+	private int displayOrientation = 0, surfaceWidth = 0, surfaceHeight = 0;
+	private boolean autoFocus = false, surfaceExists = false;
+
+	public CameraView(Context context) {
+		super(context);
+		initialize();
+	}
+
+	public CameraView(Context context, AttributeSet attrs) {
+		super(context, attrs);
+		initialize();
+	}
+
+	public CameraView(Context context, AttributeSet attrs, int defStyleAttr) {
+		super(context, attrs, defStyleAttr);
+		initialize();
+	}
+
+	private void initialize() {
+		setKeepScreenOn(true);
+		SurfaceHolder holder = getHolder();
+		if (Build.VERSION.SDK_INT < 11)
+			holder.setType(SURFACE_TYPE_PUSH_BUFFERS);
+		holder.addCallback(this);
+	}
+
+	public void start(Camera camera, PreviewConsumer previewConsumer,
+			int rotationDegrees) {
+		this.camera = camera;
+		this.previewConsumer = previewConsumer;
+		setDisplayOrientation(rotationDegrees);
+		Parameters params = camera.getParameters();
+		setFocusMode(params);
+		setPreviewSize(params);
+		applyParameters(params);
+		if (surfaceExists) startPreview(getHolder());
+	}
+
+	public void stop() {
+		stopPreview();
+		try {
+			camera.release();
+		} catch (RuntimeException e) {
+			LOG.log(WARNING, "Error releasing camera", e);
+		}
+		camera = null;
+	}
+
+	private void startPreview(SurfaceHolder holder) {
+		try {
+			camera.setPreviewDisplay(holder);
+			camera.startPreview();
+			if (autoFocus) camera.autoFocus(this);
+			previewConsumer.start(camera);
+		} catch (IOException e) {
+			LOG.log(WARNING, "Error starting camera preview", e);
+		}
+	}
+
+	private void stopPreview() {
+		try {
+			previewConsumer.stop();
+			if (autoFocus) camera.cancelAutoFocus();
+			camera.stopPreview();
+		} catch (RuntimeException e) {
+			LOG.log(WARNING, "Error stopping camera preview", e);
+		}
+	}
+
+	private void setDisplayOrientation(int rotationDegrees) {
+		int orientation;
+		CameraInfo info = new CameraInfo();
+		Camera.getCameraInfo(0, info);
+		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("Display orientation " + orientation + " degrees");
+		camera.setDisplayOrientation(orientation);
+		displayOrientation = orientation;
+	}
+
+	private void setFocusMode(Parameters params) {
+		if (Build.VERSION.SDK_INT >= 15 &&
+				params.isVideoStabilizationSupported()) {
+			LOG.info("Enabling video stabilisation");
+			params.setVideoStabilization(true);
+		}
+		// This returns null on the HTC Wildfire S
+		List<String> sceneModes = params.getSupportedSceneModes();
+		if (sceneModes == null) sceneModes = Collections.emptyList();
+		List<String> focusModes = params.getSupportedFocusModes();
+		if (LOG.isLoggable(INFO)) {
+			LOG.info("Scene modes: " + sceneModes);
+			LOG.info("Focus modes: " + focusModes);
+		}
+		if (sceneModes.contains(SCENE_MODE_BARCODE)) {
+			LOG.info("Setting scene mode to barcode");
+			params.setSceneMode(SCENE_MODE_BARCODE);
+		} else if (Build.VERSION.SDK_INT >= 14 &&
+				focusModes.contains(FOCUS_MODE_CONTINUOUS_PICTURE)) {
+			LOG.info("Setting focus mode to continuous picture");
+			params.setFocusMode(FOCUS_MODE_CONTINUOUS_PICTURE);
+		} else if (focusModes.contains(FOCUS_MODE_CONTINUOUS_VIDEO)) {
+			LOG.info("Setting focus mode to continuous video");
+			params.setFocusMode(FOCUS_MODE_CONTINUOUS_VIDEO);
+		} else if (focusModes.contains(FOCUS_MODE_EDOF)) {
+			LOG.info("Setting focus mode to EDOF");
+			params.setFocusMode(FOCUS_MODE_EDOF);
+		} else if (focusModes.contains(FOCUS_MODE_MACRO)) {
+			LOG.info("Setting focus mode to macro");
+			params.setFocusMode(FOCUS_MODE_MACRO);
+			autoFocus = true;
+		} else if (focusModes.contains(FOCUS_MODE_AUTO)) {
+			LOG.info("Setting focus mode to auto");
+			params.setFocusMode(FOCUS_MODE_AUTO);
+			autoFocus = true;
+		} else if (focusModes.contains(FOCUS_MODE_FIXED)) {
+			LOG.info("Setting focus mode to fixed");
+			params.setFocusMode(FOCUS_MODE_FIXED);
+		} else {
+			LOG.info("No suitable focus mode");
+		}
+		params.setZoom(0);
+	}
+
+	private void setPreviewSize(Parameters params) {
+		if (surfaceWidth == 0 || surfaceHeight == 0) return;
+		float idealRatio = (float) surfaceWidth / surfaceHeight;
+		DisplayMetrics screen = getContext().getResources().getDisplayMetrics();
+		int screenMax = Math.max(screen.widthPixels, screen.heightPixels);
+		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);
+			int pixels = width * height;
+			float score = width * height / stretch;
+			if (LOG.isLoggable(INFO)) {
+				LOG.info("Size " + size.width + "x" + size.height
+						+ ", stretch " + stretch + ", pixels " + pixels
+						+ ", score " + score);
+			}
+			// Large preview sizes can crash older devices
+			int maxDimension = Math.max(width, height);
+			if (Build.VERSION.SDK_INT < 14 && maxDimension > screenMax) {
+				LOG.info("Too large for screen");
+				continue;
+			}
+			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);
+		}
+	}
+
+	private void applyParameters(Parameters params) {
+		try {
+			camera.setParameters(params);
+		} catch (RuntimeException e) {
+			LOG.log(WARNING, "Error setting camera parameters", e);
+		}
+	}
+
+	public void surfaceCreated(SurfaceHolder holder) {
+		LOG.info("Surface created");
+		surfaceExists = true;
+		if (camera != null) startPreview(holder);
+	}
+
+	public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) {
+		if (LOG.isLoggable(INFO)) LOG.info("Surface changed: " + w + "x" + h);
+		surfaceWidth = w;
+		surfaceHeight = h;
+		if (camera == null) return; // We are stopped
+		stopPreview();
+		Parameters params = camera.getParameters();
+		setPreviewSize(params);
+		applyParameters(params);
+		startPreview(holder);
+	}
+
+	public void surfaceDestroyed(SurfaceHolder holder) {
+		LOG.info("Surface destroyed");
+		surfaceExists = false;
+		holder.removeCallback(this);
+	}
+
+	public void onAutoFocus(boolean success, final Camera camera) {
+		LOG.info("Auto focus succeeded: " + success);
+		postDelayed(new Runnable() {
+			public void run() {
+				retryAutoFocus();
+			}
+		}, AUTO_FOCUS_RETRY_DELAY);
+	}
+
+	private void retryAutoFocus() {
+		try {
+			if (camera != null) camera.autoFocus(this);
+		} catch (RuntimeException e) {
+			LOG.log(WARNING, "Error retrying auto focus", e);
+		}
+	}
+}
\ No newline at end of file
diff --git a/briar-android/src/org/briarproject/android/util/PreviewConsumer.java b/briar-android/src/org/briarproject/android/util/PreviewConsumer.java
new file mode 100644
index 0000000000000000000000000000000000000000..e2fb6eed9556dc44bcb79246f9aaf6179ac112f9
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/util/PreviewConsumer.java
@@ -0,0 +1,11 @@
+package org.briarproject.android.util;
+
+import android.hardware.Camera;
+
+@SuppressWarnings("deprecation")
+public interface PreviewConsumer {
+
+	void start(Camera camera);
+
+	void stop();
+}
diff --git a/briar-android/src/org/briarproject/android/util/QrCodeDecoder.java b/briar-android/src/org/briarproject/android/util/QrCodeDecoder.java
new file mode 100644
index 0000000000000000000000000000000000000000..5fdc54c39478a45b8c56ccd68aa8296406092252
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/util/QrCodeDecoder.java
@@ -0,0 +1,100 @@
+package org.briarproject.android.util;
+
+import android.hardware.Camera;
+import android.hardware.Camera.PreviewCallback;
+import android.hardware.Camera.Size;
+import android.os.AsyncTask;
+
+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 java.util.logging.Logger;
+
+import static java.util.logging.Level.INFO;
+
+@SuppressWarnings("deprecation")
+public 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 boolean stopped = false;
+
+	public QrCodeDecoder(ResultCallback callback) {
+		this.callback = callback;
+	}
+
+	public void start(Camera camera) {
+		stopped = false;
+		askForPreviewFrame(camera);
+	}
+
+	public void stop() {
+		stopped = true;
+	}
+
+	private void askForPreviewFrame(Camera camera) {
+		if (!stopped) camera.setOneShotPreviewCallback(this);
+	}
+
+	public void onPreviewFrame(byte[] data, Camera camera) {
+		if (!stopped) {
+			Size size = camera.getParameters().getPreviewSize();
+			new DecoderTask(camera, data, size.width, size.height).execute();
+		}
+	}
+
+	private class DecoderTask extends AsyncTask<Void, Void, Void> {
+
+		final Camera camera;
+		final byte[] data;
+		final int width, height;
+
+		DecoderTask(Camera camera, byte[] data, int width, int height) {
+			this.camera = camera;
+			this.data = data;
+			this.width = width;
+			this.height = height;
+		}
+
+		@Override
+		protected Void doInBackground(Void... params) {
+			long now = System.currentTimeMillis();
+			LuminanceSource src = new PlanarYUVLuminanceSource(data, width,
+					height, 0, 0, width, height, false);
+			BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(src));
+			Result result = null;
+			try {
+				result = reader.decode(bitmap);
+			} catch (ReaderException e) {
+				return null; // No barcode found
+			} finally {
+				reader.reset();
+			}
+			long duration = System.currentTimeMillis() - now;
+			if (LOG.isLoggable(INFO))
+				LOG.info("Decoding barcode took " + duration + " ms");
+			callback.handleResult(result);
+			return null;
+		}
+
+		@Override
+		protected void onPostExecute(Void result) {
+			askForPreviewFrame(camera);
+		}
+	}
+
+	public interface ResultCallback {
+
+		void handleResult(Result result);
+	}
+}
diff --git a/briar-android/src/org/briarproject/android/util/QrCodeUtils.java b/briar-android/src/org/briarproject/android/util/QrCodeUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..ef8ff6ec6714e4c6a8f444d6c26a011053a9f21d
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/util/QrCodeUtils.java
@@ -0,0 +1,51 @@
+package org.briarproject.android.util;
+
+import android.app.Activity;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.util.DisplayMetrics;
+
+import com.google.zxing.BarcodeFormat;
+import com.google.zxing.WriterException;
+import com.google.zxing.common.BitMatrix;
+import com.google.zxing.qrcode.QRCodeWriter;
+
+import java.util.logging.Logger;
+
+import static java.util.logging.Level.WARNING;
+
+public class QrCodeUtils {
+
+	private static final Logger LOG =
+			Logger.getLogger(QrCodeUtils.class.getName());
+
+	public static Bitmap createQrCode(Activity activity, String input) {
+		// Get narrowest screen dimension
+		DisplayMetrics dm = new DisplayMetrics();
+		activity.getWindowManager().getDefaultDisplay().getMetrics(dm);
+		int smallestDimen = Math.min(dm.widthPixels, dm.heightPixels);
+		try {
+			// Generate QR code
+			final BitMatrix encoded = new QRCodeWriter().encode(
+					input, BarcodeFormat.QR_CODE, smallestDimen, smallestDimen);
+			// Convert QR code to Bitmap
+			int width = encoded.getWidth();
+			int height = encoded.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] =
+							encoded.get(x, y) ? Color.BLACK : Color.WHITE;
+				}
+			}
+			Bitmap qr = Bitmap.createBitmap(width, height,
+					Bitmap.Config.ARGB_8888);
+			qr.setPixels(pixels, 0, width, 0, 0, width, height);
+			return qr;
+		} catch (WriterException e) {
+			if (LOG.isLoggable(WARNING))
+				LOG.log(WARNING, e.toString(), e);
+			return null;
+		}
+	}
+}
diff --git a/briar-core/build.gradle b/briar-core/build.gradle
index 7fe4201a76c6f6e93d5f4aa4857015fcdf92e0cf..94452e1e08860a5dfc8fd53f3bc6b2e4515e7402 100644
--- a/briar-core/build.gradle
+++ b/briar-core/build.gradle
@@ -13,12 +13,14 @@ dependencies {
 	compile fileTree(dir: 'libs', include: '*.jar')
 	compile "com.madgag.spongycastle:core:1.54.0.0"
 	compile "com.h2database:h2:1.4.190"
+	compile "com.google.zxing:core:3.2.1"
 }
 
 dependencyVerification {
 	verify = [
 			'com.madgag.spongycastle:core:1e7fa4b19ccccd1011364ab838d0b4702470c178bbbdd94c5c90b2d4d749ea1e',
 			'com.h2database:h2:23ba495a07bbbb3bd6c3084d10a96dad7a23741b8b6d64b213459a784195a98c',
+			'com.google.zxing:core:b4d82452e7a6bf6ec2698904b332431717ed8f9a850224f295aec89de80f2259',
 	]
 }