diff --git a/src/net/sf/briar/invitation/AliceConnector.java b/src/net/sf/briar/invitation/AliceConnector.java
new file mode 100644
index 0000000000000000000000000000000000000000..0d8bd5b43f55bce09b3b75d3279de40aa365e5f0
--- /dev/null
+++ b/src/net/sf/briar/invitation/AliceConnector.java
@@ -0,0 +1,121 @@
+package net.sf.briar.invitation;
+
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+import static net.sf.briar.api.plugins.InvitationConstants.HASH_LENGTH;
+import static net.sf.briar.api.plugins.InvitationConstants.INVITATION_TIMEOUT;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.Logger;
+
+import net.sf.briar.api.crypto.PseudoRandom;
+import net.sf.briar.api.invitation.ConnectionCallback;
+import net.sf.briar.api.plugins.duplex.DuplexPlugin;
+import net.sf.briar.api.plugins.duplex.DuplexTransportConnection;
+
+class AliceConnector extends Thread {
+
+	private static final Logger LOG =
+			Logger.getLogger(AliceConnector.class.getName());
+
+	private final DuplexPlugin plugin;
+	private final PseudoRandom random;
+	private final ConnectionCallback callback;
+	private final AtomicBoolean connected, succeeded;
+	private final String pluginName;
+
+	AliceConnector(DuplexPlugin plugin, PseudoRandom random,
+			ConnectionCallback callback, AtomicBoolean connected,
+			AtomicBoolean succeeded) {
+		this.plugin = plugin;
+		this.random = random;
+		this.callback = callback;
+		this.connected = connected;
+		this.succeeded = succeeded;
+		pluginName = plugin.getClass().getName();
+	}
+
+	@Override
+	public void run() {
+		long halfTime = System.currentTimeMillis() + INVITATION_TIMEOUT / 2;
+		DuplexTransportConnection conn = makeOutgoingConnection();
+		if(conn == null) conn = acceptIncomingConnection(halfTime);
+		if(conn == null) return;
+		if(LOG.isLoggable(INFO)) LOG.info(pluginName + " connected");
+		// Don't proceed with more than one connection
+		if(connected.getAndSet(true)) {
+			if(LOG.isLoggable(INFO)) LOG.info(pluginName + " redundant");
+			tryToClose(conn, false);
+			return;
+		}
+		// FIXME: Carry out the real invitation protocol
+		InputStream in;
+		try {
+			in = conn.getInputStream();
+			OutputStream out = conn.getOutputStream();
+			byte[] hash = random.nextBytes(HASH_LENGTH);
+			out.write(hash);
+			out.flush();
+			if(LOG.isLoggable(INFO)) LOG.info(pluginName + " sent hash");
+			int offset = 0;
+			while(offset < hash.length) {
+				int read = in.read(hash, offset, hash.length - offset);
+				if(read == -1) break;
+				offset += read;
+			}
+			if(offset < HASH_LENGTH) throw new EOFException();
+			if(LOG.isLoggable(INFO)) LOG.info(pluginName + " received hash");
+			if(LOG.isLoggable(INFO)) LOG.info(pluginName + " succeeded");
+			succeeded.set(true);
+			callback.connectionEstablished(123456, 123456,
+					new ConfirmationSender(out));
+		} catch(IOException e) {
+			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
+			tryToClose(conn, true);
+			return;
+		}
+		try {
+			if(in.read() == 1) callback.codesMatch();
+			else callback.codesDoNotMatch();
+		} catch(IOException e) {
+			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
+			tryToClose(conn, true);
+			callback.codesDoNotMatch();
+		}
+	}
+
+	private DuplexTransportConnection makeOutgoingConnection() {
+		if(LOG.isLoggable(INFO))
+			LOG.info(pluginName + " making outgoing connection");
+		return plugin.sendInvitation(random, INVITATION_TIMEOUT / 2);
+	}
+
+	private DuplexTransportConnection acceptIncomingConnection(long halfTime) {
+		long now = System.currentTimeMillis();
+		if(now < halfTime) {
+			if(LOG.isLoggable(INFO))
+				LOG.info(pluginName + " sleeping until half-time");
+			try {
+				Thread.sleep(halfTime - now);
+			} catch(InterruptedException e) {
+				if(LOG.isLoggable(INFO)) LOG.info("Interrupted while sleeping");
+				return null;
+			}
+		}
+		if(LOG.isLoggable(INFO))
+			LOG.info(pluginName + " accepting incoming connection");
+		return plugin.acceptInvitation(random, INVITATION_TIMEOUT / 2);
+	}
+
+	private void tryToClose(DuplexTransportConnection conn, boolean exception) {
+		try {
+			conn.dispose(exception, true);
+		} catch(IOException e) {
+			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
+		}
+	}
+}
\ No newline at end of file
diff --git a/src/net/sf/briar/invitation/BobConnector.java b/src/net/sf/briar/invitation/BobConnector.java
new file mode 100644
index 0000000000000000000000000000000000000000..a895d638ecbf66fcef674f9eefe9707521024ee2
--- /dev/null
+++ b/src/net/sf/briar/invitation/BobConnector.java
@@ -0,0 +1,121 @@
+package net.sf.briar.invitation;
+
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+import static net.sf.briar.api.plugins.InvitationConstants.HASH_LENGTH;
+import static net.sf.briar.api.plugins.InvitationConstants.INVITATION_TIMEOUT;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.Logger;
+
+import net.sf.briar.api.crypto.PseudoRandom;
+import net.sf.briar.api.invitation.ConnectionCallback;
+import net.sf.briar.api.plugins.duplex.DuplexPlugin;
+import net.sf.briar.api.plugins.duplex.DuplexTransportConnection;
+
+class BobConnector extends Thread {
+
+	private static final Logger LOG =
+			Logger.getLogger(BobConnector.class.getName());
+
+	private final DuplexPlugin plugin;
+	private final PseudoRandom random;
+	private final ConnectionCallback callback;
+	private final AtomicBoolean connected, succeeded;
+	private final String pluginName;
+
+	BobConnector(DuplexPlugin plugin, PseudoRandom random,
+			ConnectionCallback callback, AtomicBoolean connected,
+			AtomicBoolean succeeded) {
+		this.plugin = plugin;
+		this.random = random;
+		this.callback = callback;
+		this.connected = connected;
+		this.succeeded = succeeded;
+		pluginName = plugin.getClass().getName();
+	}
+
+	@Override
+	public void run() {
+		long halfTime = System.currentTimeMillis() + INVITATION_TIMEOUT / 2;
+		DuplexTransportConnection conn = acceptIncomingConnection();
+		if(conn == null) conn = makeOutgoingConnection(halfTime);
+		if(conn == null) return;
+		if(LOG.isLoggable(INFO)) LOG.info(pluginName + " connected");
+		// FIXME: Carry out the real invitation protocol
+		InputStream in;
+		try {
+			in = conn.getInputStream();
+			OutputStream out = conn.getOutputStream();
+			byte[] hash = new byte[HASH_LENGTH];
+			int offset = 0;
+			while(offset < hash.length) {
+				int read = in.read(hash, offset, hash.length - offset);
+				if(read == -1) break;
+				offset += read;
+			}
+			if(offset < HASH_LENGTH) throw new EOFException();
+			if(LOG.isLoggable(INFO)) LOG.info(pluginName + " received hash");
+			// Don't proceed with more than one connection
+			if(connected.getAndSet(true)) {
+				if(LOG.isLoggable(INFO))
+					LOG.info(pluginName + " redundant");
+				tryToClose(conn, false);
+				return;
+			}
+			out.write(hash);
+			out.flush();
+			if(LOG.isLoggable(INFO)) LOG.info(pluginName + " sent hash");
+			succeeded.set(true);
+			callback.connectionEstablished(123456, 123456,
+					new ConfirmationSender(out));
+		} catch(IOException e) {
+			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
+			tryToClose(conn, true);
+			return;
+		}
+		try {
+			if(in.read() == 1) callback.codesMatch();
+			else callback.codesDoNotMatch();
+		} catch(IOException e) {
+			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
+			tryToClose(conn, true);
+			callback.codesDoNotMatch();
+		}
+	}
+
+	private DuplexTransportConnection acceptIncomingConnection() {
+		if(LOG.isLoggable(INFO))
+			LOG.info(pluginName + " accepting incoming connection");
+		return plugin.acceptInvitation(random, INVITATION_TIMEOUT / 2);
+	}
+
+	private DuplexTransportConnection makeOutgoingConnection(long halfTime) {
+		long now = System.currentTimeMillis();
+		if(now < halfTime) {
+			if(LOG.isLoggable(INFO))
+				LOG.info(pluginName + " sleeping until half-time");
+			try {
+				Thread.sleep(halfTime - now);
+			} catch(InterruptedException e) {
+				if(LOG.isLoggable(INFO)) LOG.info("Interrupted while sleeping");
+				return null;
+			}
+		}
+		if(LOG.isLoggable(INFO))
+			LOG.info(pluginName + " making outgoing connection");
+		return plugin.sendInvitation(random, INVITATION_TIMEOUT / 2);
+	}
+
+	private void tryToClose(DuplexTransportConnection conn, boolean exception) {
+		try {
+			conn.dispose(exception, true);
+		} catch(IOException e) {
+			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
+		}
+	}
+}
diff --git a/src/net/sf/briar/invitation/ConfirmationSender.java b/src/net/sf/briar/invitation/ConfirmationSender.java
new file mode 100644
index 0000000000000000000000000000000000000000..7542b49f02c4ee4e77b2a473c96ad43fdb20f837
--- /dev/null
+++ b/src/net/sf/briar/invitation/ConfirmationSender.java
@@ -0,0 +1,38 @@
+package net.sf.briar.invitation;
+
+import static java.util.logging.Level.WARNING;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.logging.Logger;
+
+import net.sf.briar.api.invitation.ConfirmationCallback;
+
+class ConfirmationSender implements ConfirmationCallback {
+
+	private static final Logger LOG =
+			Logger.getLogger(ConfirmationSender.class.getName());
+
+	private final OutputStream out;
+
+	ConfirmationSender(OutputStream out) {
+		this.out = out;
+	}
+
+	public void codesMatch() {
+		write(1);
+	}
+
+	public void codesDoNotMatch() {
+		write(0);
+	}
+
+	private void write(int b) {
+		try {
+			out.write(b);
+			out.flush();
+		} catch(IOException e) {
+			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
+		}
+	}
+}
diff --git a/src/net/sf/briar/invitation/FailureNotifier.java b/src/net/sf/briar/invitation/FailureNotifier.java
new file mode 100644
index 0000000000000000000000000000000000000000..7379a7e930c85bf70d2d4a0a66fb689b184b66bf
--- /dev/null
+++ b/src/net/sf/briar/invitation/FailureNotifier.java
@@ -0,0 +1,42 @@
+package net.sf.briar.invitation;
+
+import static java.util.logging.Level.INFO;
+
+import java.util.Collection;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.Logger;
+
+import net.sf.briar.api.invitation.ConnectionCallback;
+
+class FailureNotifier extends Thread {
+
+	private static final Logger LOG =
+			Logger.getLogger(FailureNotifier.class.getName());
+
+	private final Collection<Thread> workers;
+	private final AtomicBoolean succeeded;
+	private final ConnectionCallback callback;
+
+	FailureNotifier(Collection<Thread> workers, AtomicBoolean succeeded,
+			ConnectionCallback callback) {
+		this.workers = workers;
+		this.succeeded = succeeded;
+		this.callback = callback;
+	}
+
+	@Override
+	public void run() {
+		if(LOG.isLoggable(INFO)) LOG.info(workers.size() + " workers");
+		try {
+			for(Thread worker : workers) worker.join();
+			if(!succeeded.get()) {
+				if(LOG.isLoggable(INFO)) LOG.info("No worker succeeded");
+				callback.connectionNotEstablished();
+			}
+		} catch(InterruptedException e) {
+			if(LOG.isLoggable(INFO))
+				LOG.info("Interrupted while waiting for workers");
+			callback.connectionNotEstablished();
+		}
+	}
+}
diff --git a/src/net/sf/briar/invitation/InvitationManagerImpl.java b/src/net/sf/briar/invitation/InvitationManagerImpl.java
index 4d4967a068d858b697e5383724c16d38b75060b0..a098add93ac3e36fa86b0dd0ee8deb5a4fd76a14 100644
--- a/src/net/sf/briar/invitation/InvitationManagerImpl.java
+++ b/src/net/sf/briar/invitation/InvitationManagerImpl.java
@@ -1,10 +1,11 @@
 package net.sf.briar.invitation;
 
+import java.util.ArrayList;
 import java.util.Collection;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 import net.sf.briar.api.crypto.CryptoComponent;
 import net.sf.briar.api.crypto.PseudoRandom;
-import net.sf.briar.api.invitation.ConfirmationCallback;
 import net.sf.briar.api.invitation.ConnectionCallback;
 import net.sf.briar.api.invitation.InvitationManager;
 import net.sf.briar.api.plugins.PluginManager;
@@ -27,55 +28,37 @@ class InvitationManagerImpl implements InvitationManager {
 		Collection<DuplexPlugin> plugins = pluginManager.getInvitationPlugins();
 		// Alice is the party with the smaller invitation code
 		if(localCode < remoteCode) {
-			PseudoRandom r = crypto.getPseudoRandom(localCode, remoteCode);
-			startAliceInvitationWorker(plugins, r, c);
+			startAliceWorkers(plugins, localCode, remoteCode, c);
 		} else {
-			PseudoRandom r = crypto.getPseudoRandom(remoteCode, localCode);
-			startBobInvitationWorker(plugins, r, c);
+			startBobWorkers(plugins, localCode, remoteCode, c);
 		}
 	}
 
-	private void startAliceInvitationWorker(Collection<DuplexPlugin> plugins,
-			PseudoRandom r, ConnectionCallback c) {
-		// FIXME
-		new FakeWorkerThread(c).start();
-	}
-
-	private void startBobInvitationWorker(Collection<DuplexPlugin> plugins,
-			PseudoRandom r, ConnectionCallback c) {
-		// FIXME
-		new FakeWorkerThread(c).start();
-	}
-
-	private static class FakeWorkerThread extends Thread {
-
-		private final ConnectionCallback callback;
-
-		private FakeWorkerThread(ConnectionCallback callback) {
-			this.callback = callback;
+	private void startAliceWorkers(Collection<DuplexPlugin> plugins,
+			int localCode, int remoteCode, ConnectionCallback c) {
+		AtomicBoolean connected = new AtomicBoolean(false);
+		AtomicBoolean succeeded = new AtomicBoolean(false);
+		Collection<Thread> workers = new ArrayList<Thread>();
+		for(DuplexPlugin p : plugins) {
+			PseudoRandom r = crypto.getPseudoRandom(localCode, remoteCode);
+			Thread worker = new AliceConnector(p, r, c, connected, succeeded);
+			workers.add(worker);
+			worker.start();
 		}
+		new FailureNotifier(workers, succeeded, c).start();
+	}
 
-		@Override
-		public void run() {
-			try {
-				Thread.sleep((long) (Math.random() * 30 * 1000));
-			} catch(InterruptedException ignored) {}
-			if(Math.random() < 0.8) {
-				callback.connectionNotEstablished();
-			} else {
-				callback.connectionEstablished(123456, 123456,
-						new ConfirmationCallback() {
-
-					public void codesMatch() {}
-
-					public void codesDoNotMatch() {}
-				});
-				try {
-					Thread.sleep((long) (Math.random() * 10 * 1000));
-				} catch(InterruptedException ignored) {}
-				if(Math.random() < 0.5) callback.codesMatch();
-				else callback.codesDoNotMatch();
-			}
+	private void startBobWorkers(Collection<DuplexPlugin> plugins,
+			int localCode, int remoteCode, ConnectionCallback c) {
+		AtomicBoolean connected = new AtomicBoolean(false);
+		AtomicBoolean succeeded = new AtomicBoolean(false);
+		Collection<Thread> workers = new ArrayList<Thread>();
+		for(DuplexPlugin p : plugins) {
+			PseudoRandom r = crypto.getPseudoRandom(remoteCode, localCode);
+			Thread worker = new BobConnector(p, r, c, connected, succeeded);
+			workers.add(worker);
+			worker.start();
 		}
+		new FailureNotifier(workers, succeeded, c).start();
 	}
 }
diff --git a/src/net/sf/briar/plugins/droidtooth/DroidtoothPlugin.java b/src/net/sf/briar/plugins/droidtooth/DroidtoothPlugin.java
index bfd543d06f91677f21c4725c3a5cc7713ffb6f22..e9ddc1daab114bace343df3a678798e42c91ccc3 100644
--- a/src/net/sf/briar/plugins/droidtooth/DroidtoothPlugin.java
+++ b/src/net/sf/briar/plugins/droidtooth/DroidtoothPlugin.java
@@ -1,14 +1,10 @@
 package net.sf.briar.plugins.droidtooth;
 
-import static android.bluetooth.BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE;
 import static android.bluetooth.BluetoothAdapter.ACTION_STATE_CHANGED;
-import static android.bluetooth.BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION;
 import static android.bluetooth.BluetoothAdapter.EXTRA_STATE;
-import static android.bluetooth.BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE;
 import static android.bluetooth.BluetoothAdapter.STATE_OFF;
 import static android.bluetooth.BluetoothAdapter.STATE_ON;
 import static android.bluetooth.BluetoothDevice.EXTRA_DEVICE;
-import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.logging.Level.INFO;
 import static java.util.logging.Level.WARNING;
@@ -265,6 +261,7 @@ class DroidtoothPlugin implements DuplexPlugin {
 		// Try to connect
 		try {
 			BluetoothSocket s = InsecureBluetooth.createSocket(d, u);
+			s.connect();
 			return new DroidtoothTransportConnection(s);
 		} catch(IOException e) {
 			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
@@ -320,8 +317,6 @@ class DroidtoothPlugin implements DuplexPlugin {
 		}
 		// Use the same pseudo-random UUID as the contact
 		UUID uuid = UUID.nameUUIDFromBytes(r.nextBytes(16));
-		// Make the device discoverable if the user allows it
-		makeDeviceDiscoverable();
 		// Bind a new server socket to accept the invitation connection
 		final BluetoothServerSocket ss;
 		try {
@@ -344,17 +339,6 @@ class DroidtoothPlugin implements DuplexPlugin {
 		}
 	}
 
-	private void makeDeviceDiscoverable() {
-		synchronized(this) {
-			if(!running) return;
-		}
-		if(adapter.getScanMode() == SCAN_MODE_CONNECTABLE_DISCOVERABLE) return;
-		Intent i = new Intent(ACTION_REQUEST_DISCOVERABLE);
-		i.putExtra(EXTRA_DISCOVERABLE_DURATION, 120);
-		i.addFlags(FLAG_ACTIVITY_NEW_TASK);
-		appContext.startActivity(i);
-	}
-
 	private static class BluetoothStateReceiver extends BroadcastReceiver {
 
 		private final CountDownLatch finished = new CountDownLatch(1);
diff --git a/src/net/sf/briar/plugins/droidtooth/InsecureBluetooth.java b/src/net/sf/briar/plugins/droidtooth/InsecureBluetooth.java
index 05b7a31ab7ecd24951423d21ab5050c1a758c7b1..3e783daf97e660cd2b9d53bca1746c969d152d9b 100644
--- a/src/net/sf/briar/plugins/droidtooth/InsecureBluetooth.java
+++ b/src/net/sf/briar/plugins/droidtooth/InsecureBluetooth.java
@@ -65,9 +65,7 @@ class InsecureBluetooth {
 			int handle = (Integer) addRfcommServiceRecord.invoke(mService, name,
 					new ParcelUuid(uuid), channel, new Binder());
 			if(handle == -1) {
-				try {
-					socket.close();
-				} catch(IOException ignored) {}
+				socket.close();
 				throw new IOException("Can't register SDP record for " + name);
 			}
 			Field f1 = adapter.getClass().getDeclaredField("mHandler");
@@ -117,9 +115,7 @@ class InsecureBluetooth {
 			Object result = bindListen.invoke(mSocket, new Object[0]);
 			int errno = (Integer) result;
 			if(errno != 0) {
-				try {
-					socket.close();
-				} catch(IOException ignored) {}
+				socket.close();
 				Method throwErrnoNative = mSocket.getClass().getMethod(
 						"throwErrnoNative", int.class);
 				throwErrnoNative.invoke(mSocket, errno);
@@ -145,9 +141,8 @@ class InsecureBluetooth {
 	@SuppressLint("NewApi")
 	static BluetoothSocket createSocket(BluetoothDevice device, UUID uuid)
 			throws IOException {
-		if(Build.VERSION.SDK_INT >= 10) {
+		if(Build.VERSION.SDK_INT >= 10)
 			return device.createInsecureRfcommSocketToServiceRecord(uuid);
-		}
 		try {
 			BluetoothSocket socket = null;
 			Constructor<BluetoothSocket> constructor =
diff --git a/src/net/sf/briar/plugins/tcp/TcpPlugin.java b/src/net/sf/briar/plugins/tcp/TcpPlugin.java
index f476aad47b60139d81e53d3453390385f82b523a..10c704e9be69661475417a7f9a61be07e3cc0422 100644
--- a/src/net/sf/briar/plugins/tcp/TcpPlugin.java
+++ b/src/net/sf/briar/plugins/tcp/TcpPlugin.java
@@ -176,9 +176,9 @@ abstract class TcpPlugin implements DuplexPlugin {
 			if(!running) return null;
 		}
 		SocketAddress addr = getRemoteSocketAddress(c);
+		Socket s = new Socket();
+		if(addr == null || s == null) return null;
 		try {
-			Socket s = new Socket();
-			if(addr == null || s == null) return null;
 			s.connect(addr);
 			return new TcpTransportConnection(s);
 		} catch(IOException e) {