diff --git a/api/net/sf/briar/api/plugins/BatchPlugin.java b/api/net/sf/briar/api/plugins/BatchPlugin.java
index 47f1d5a5de9b2e29d5aea466b026eedd33e57d33..26d41bd48741d5c334d6820b20b8f2cb5d9c7ec7 100644
--- a/api/net/sf/briar/api/plugins/BatchPlugin.java
+++ b/api/net/sf/briar/api/plugins/BatchPlugin.java
@@ -23,4 +23,28 @@ public interface BatchPlugin extends Plugin {
 	 * Returns null if a writer could not be created.
 	 */
 	BatchTransportWriter createWriter(ContactId c);
+
+	/**
+	 * Starts the invitation process from the inviter's side. Returns null if
+	 * no connection can be established within the given timeout.
+	 */
+	BatchTransportWriter sendInvitation(int code, long timeout);
+
+	/**
+	 * Starts the invitation process from the invitee's side. Returns null if
+	 * no connection can be established within the given timeout.
+	 */
+	BatchTransportReader acceptInvitation(int code, long timeout);
+
+	/**
+	 * Continues the invitation process from the invitee's side. Returns null
+	 * if no connection can be established within the given timeout.
+	 */
+	BatchTransportWriter sendInvitationResponse(int code, long timeout);
+
+	/**
+	 * Continues the invitation process from the inviter's side. Returns null
+	 * if no connection can be established within the given timeout.
+	 */
+	BatchTransportReader acceptInvitationResponse(int code, long timeout);
 }
diff --git a/api/net/sf/briar/api/plugins/StreamPlugin.java b/api/net/sf/briar/api/plugins/StreamPlugin.java
index 1cd88937059982707000b79544f83ec720ce3ec7..bceae8ec947a475f4fa3c534dd303747ac259a63 100644
--- a/api/net/sf/briar/api/plugins/StreamPlugin.java
+++ b/api/net/sf/briar/api/plugins/StreamPlugin.java
@@ -15,4 +15,16 @@ public interface StreamPlugin extends Plugin {
 	 * Returns null if a connection could not be created.
 	 */
 	StreamTransportConnection createConnection(ContactId c);
+
+	/**
+	 * Starts the invitation process from the inviter's side. Returns null if
+	 * no connection can be established within the given timeout.
+	 */
+	StreamTransportConnection sendInvitation(int code, long timeout);
+
+	/**
+	 * Starts the invitation process from the invitee's side. Returns null if
+	 * no connection can be established within the given timeout.
+	 */
+	StreamTransportConnection acceptInvitation(int code, long timeout);
 }
diff --git a/components/net/sf/briar/db/DatabaseCleanerImpl.java b/components/net/sf/briar/db/DatabaseCleanerImpl.java
index 37748ad18289dbe6a2b0d9c80800476c1cdf40be..b4199a6b087e9b260e8ffd5b2ec514b5a9558aac 100644
--- a/components/net/sf/briar/db/DatabaseCleanerImpl.java
+++ b/components/net/sf/briar/db/DatabaseCleanerImpl.java
@@ -36,7 +36,10 @@ class DatabaseCleanerImpl implements DatabaseCleaner, Runnable {
 					} else {
 						try {
 							wait(msBetweenSweeps);
-						} catch(InterruptedException ignored) {}
+						} catch(InterruptedException e) {
+							if(LOG.isLoggable(Level.WARNING))
+								LOG.warning(e.getMessage());
+						}
 					}
 				} catch(DbException e) {
 					if(LOG.isLoggable(Level.WARNING))
diff --git a/components/net/sf/briar/db/JdbcDatabase.java b/components/net/sf/briar/db/JdbcDatabase.java
index cafa95ec9f465347c146a33854214a03abba8be1..16e691135118dc18378a28f2096064d51cfd60c3 100644
--- a/components/net/sf/briar/db/JdbcDatabase.java
+++ b/components/net/sf/briar/db/JdbcDatabase.java
@@ -362,7 +362,10 @@ abstract class JdbcDatabase implements Database<Connection> {
 			while(closed) {
 				try {
 					connections.wait();
-				} catch(InterruptedException ignored) {}
+				} catch(InterruptedException e) {
+					if(LOG.isLoggable(Level.WARNING))
+						LOG.warning(e.getMessage());
+				}
 			}
 			txn = connections.poll();
 		}
@@ -433,7 +436,10 @@ abstract class JdbcDatabase implements Database<Connection> {
 							+ " open connections");
 				try {
 					connections.wait();
-				} catch(InterruptedException ignored) {}
+				} catch(InterruptedException e) {
+					if(LOG.isLoggable(Level.WARNING))
+						LOG.warning(e.getMessage());
+				}
 				for(Connection c : connections) c.close();
 				openConnections -= connections.size();
 				connections.clear();
diff --git a/components/net/sf/briar/i18n/I18nImpl.java b/components/net/sf/briar/i18n/I18nImpl.java
index 106b3032fb792e0045666da449def991e4c2b32c..6bda4a2a13570a0aa71078fcff93e774f8649f34 100644
--- a/components/net/sf/briar/i18n/I18nImpl.java
+++ b/components/net/sf/briar/i18n/I18nImpl.java
@@ -12,15 +12,17 @@ import java.util.MissingResourceException;
 import java.util.ResourceBundle;
 import java.util.Scanner;
 import java.util.Set;
+import java.util.logging.Level;
+import java.util.logging.Logger;
 
 import javax.swing.UIManager;
 
-import com.google.inject.Inject;
-
 import net.sf.briar.api.i18n.FontManager;
 import net.sf.briar.api.i18n.I18n;
 import net.sf.briar.util.FileUtils;
 
+import com.google.inject.Inject;
+
 // Needs to be public for installer
 public class I18nImpl implements I18n {
 
@@ -70,6 +72,9 @@ public class I18nImpl implements I18n {
 		"ProgressMonitor.progressText"
 	};
 
+	private static final Logger LOG =
+		Logger.getLogger(I18nImpl.class.getName());
+
 	private final Object bundleLock = new Object();
 	private final ClassLoader loader = I18n.class.getClassLoader();
 	private final Set<Listener> listeners = new HashSet<Listener>();
@@ -97,7 +102,10 @@ public class I18nImpl implements I18n {
 					for(String key : uiManagerKeys) {
 						try {
 							UIManager.put(key, bundle.getString(key));
-						} catch(MissingResourceException ignored) {}
+						} catch(MissingResourceException e) {
+							if(LOG.isLoggable(Level.WARNING))
+								LOG.warning(e.getMessage());
+						}
 					}
 				}
 			}
diff --git a/components/net/sf/briar/plugins/AbstractPlugin.java b/components/net/sf/briar/plugins/AbstractPlugin.java
index 1b1c1ff189bc106f52a6457d8c3870af95e852fc..6a779ef7a8857dfd88eb577d31c491b7f1a6f51d 100644
--- a/components/net/sf/briar/plugins/AbstractPlugin.java
+++ b/components/net/sf/briar/plugins/AbstractPlugin.java
@@ -9,8 +9,7 @@ public abstract class AbstractPlugin implements Plugin {
 
 	protected final Executor executor;
 
-	// This field must only be accessed with this's lock held
-	protected boolean started = false;
+	protected boolean started = false; // Locking: this
 
 	protected AbstractPlugin(Executor executor) {
 		this.executor = executor;
diff --git a/components/net/sf/briar/plugins/PollerImpl.java b/components/net/sf/briar/plugins/PollerImpl.java
index 5905fd243f31c92835750360537ed71272c107d2..87ee77c0d4fd85937e6c325621aade127c70f94b 100644
--- a/components/net/sf/briar/plugins/PollerImpl.java
+++ b/components/net/sf/briar/plugins/PollerImpl.java
@@ -51,7 +51,10 @@ class PollerImpl implements Poller, Runnable {
 				} else {
 					try {
 						wait(p.time - now);
-					} catch(InterruptedException ignored) {}
+					} catch(InterruptedException e) {
+						if(LOG.isLoggable(Level.WARNING))
+							LOG.warning(e.getMessage());
+					}
 				}
 			}
 		}
diff --git a/components/net/sf/briar/plugins/bluetooth/BluetoothPlugin.java b/components/net/sf/briar/plugins/bluetooth/BluetoothPlugin.java
index d5db89a7200030a36623da072187e524051e9e6d..d63eeaa600e4165987001e8ed2fdd8f58341b49f 100644
--- a/components/net/sf/briar/plugins/bluetooth/BluetoothPlugin.java
+++ b/components/net/sf/briar/plugins/bluetooth/BluetoothPlugin.java
@@ -21,8 +21,8 @@ import net.sf.briar.api.ContactId;
 import net.sf.briar.api.TransportConfig;
 import net.sf.briar.api.TransportId;
 import net.sf.briar.api.TransportProperties;
-import net.sf.briar.api.plugins.StreamPluginCallback;
 import net.sf.briar.api.plugins.StreamPlugin;
+import net.sf.briar.api.plugins.StreamPluginCallback;
 import net.sf.briar.api.transport.StreamTransportConnection;
 import net.sf.briar.plugins.AbstractPlugin;
 import net.sf.briar.util.OsUtils;
@@ -36,11 +36,12 @@ class BluetoothPlugin extends AbstractPlugin implements StreamPlugin {
 	private static final Logger LOG =
 		Logger.getLogger(BluetoothPlugin.class.getName());
 
+	private final Object discoveryLock = new Object();
 	private final StreamPluginCallback callback;
 	private final long pollingInterval;
 
-	private LocalDevice localDevice = null;
-	private StreamConnectionNotifier streamConnectionNotifier = null;
+	private LocalDevice localDevice = null; // Locking: this
+	private StreamConnectionNotifier socket = null; // Locking: this
 
 	BluetoothPlugin(Executor executor, StreamPluginCallback callback,
 			long pollingInterval) {
@@ -72,40 +73,35 @@ class BluetoothPlugin extends AbstractPlugin implements StreamPlugin {
 			}
 			throw new IOException(e.getMessage());
 		}
-		executor.execute(createBinder());
+		executor.execute(createContactSocketBinder());
 	}
 
 	@Override
 	public synchronized void stop() throws IOException {
 		super.stop();
-		if(streamConnectionNotifier != null) {
-			streamConnectionNotifier.close();
-			streamConnectionNotifier = null;
+		if(socket != null) {
+			socket.close();
+			socket = null;
 		}
 	}
 
-	private Runnable createBinder() {
+	private Runnable createContactSocketBinder() {
 		return new Runnable() {
 			public void run() {
-				bind();
+				bindContactSocket();
 			}
 		};
 	}
 
-	private void bind() {
+	private void bindContactSocket() {
 		String uuid;
 		synchronized(this) {
 			if(!started) return;
 			uuid = getUuid();
+			makeDeviceDiscoverable();
 		}
-		// Try to make the device discoverable (requires root on Linux)
-		try {
-			localDevice.setDiscoverable(DiscoveryAgent.GIAC);
-		} catch(BluetoothStateException e) {
-			if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
-		}
-		// Bind the port
-		String url = "btspp://localhost:" + uuid + ";name=" + uuid;
+		// Bind the socket
+		String url = "btspp://localhost:" + uuid + ";name=RFCOMM";
 		StreamConnectionNotifier scn;
 		try {
 			scn = (StreamConnectionNotifier) Connector.open(url);
@@ -123,10 +119,10 @@ class BluetoothPlugin extends AbstractPlugin implements StreamPlugin {
 				}
 				return;
 			}
-			streamConnectionNotifier = scn;
+			socket = scn;
 			setLocalBluetoothAddress(localDevice.getBluetoothAddress());
 		}
-		startListener();
+		startContactAccepterThread();
 	}
 
 	private synchronized String getUuid() {
@@ -144,42 +140,52 @@ class BluetoothPlugin extends AbstractPlugin implements StreamPlugin {
 		return uuid;
 	}
 
-	private void startListener() {
+	private synchronized void makeDeviceDiscoverable() {
+		assert started;
+		// Try to make the device discoverable (requires root on Linux)
+		try {
+			localDevice.setDiscoverable(DiscoveryAgent.GIAC);
+		} catch(BluetoothStateException e) {
+			if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
+		}
+	}
+
+	private synchronized void setLocalBluetoothAddress(String address) {
+		assert started;
+		TransportProperties p = callback.getLocalProperties();
+		p.put("address", address);
+		callback.setLocalProperties(p);
+	}
+
+	private void startContactAccepterThread() {
 		new Thread() {
 			@Override
 			public void run() {
-				listen();
+				acceptContactConnections();
 			}
 		}.start();
 	}
 
-	private void listen() {
+	private void acceptContactConnections() {
 		while(true) {
 			StreamConnectionNotifier scn;
 			StreamConnection s;
 			synchronized(this) {
 				if(!started) return;
-				scn = streamConnectionNotifier;
+				scn = socket;
 			}
 			try {
 				s = scn.acceptAndOpen();
 			} catch(IOException e) {
-				if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
+				// This is expected when the socket is closed
+				if(LOG.isLoggable(Level.INFO)) LOG.info(e.getMessage());
 				return;
 			}
-			BluetoothTransportConnection conn =
-				new BluetoothTransportConnection(s);
-			callback.incomingConnectionCreated(conn);
+			callback.incomingConnectionCreated(
+					new BluetoothTransportConnection(s));
 		}
 	}
 
-	private synchronized void setLocalBluetoothAddress(String address) {
-		assert started;
-		TransportProperties p = callback.getLocalProperties();
-		p.put("address", address);
-		callback.setLocalProperties(p);
-	}
-
 	public boolean shouldPoll() {
 		return true;
 	}
@@ -202,58 +208,53 @@ class BluetoothPlugin extends AbstractPlugin implements StreamPlugin {
 	}
 
 	private void connectAndCallBack() {
-		Map<ContactId, String> discovered = discover();
+		Map<ContactId, String> discovered = discoverContactUrls();
 		for(Entry<ContactId, String> e : discovered.entrySet()) {
 			ContactId c = e.getKey();
 			String url = e.getValue();
-			StreamTransportConnection conn = connect(c, url);
-			if(conn != null) callback.outgoingConnectionCreated(c, conn);
+			StreamTransportConnection s = connect(c, url);
+			if(s != null) callback.outgoingConnectionCreated(c, s);
 		}
 	}
 
-	private Map<ContactId, String> discover() {
+	private Map<ContactId, String> discoverContactUrls() {
 		DiscoveryAgent discoveryAgent;
-		Map<String, ContactId> addresses;
-		Map<ContactId, String> uuids;
+		Map<ContactId, TransportProperties> remote;
 		synchronized(this) {
 			if(!started) return Collections.emptyMap();
 			discoveryAgent = localDevice.getDiscoveryAgent();
-			addresses = new HashMap<String, ContactId>();
-			uuids = new HashMap<ContactId, String>();
-			Map<ContactId, TransportProperties> remote =
-				callback.getRemoteProperties();
-			for(Entry<ContactId, TransportProperties> e : remote.entrySet()) {
-				ContactId c = e.getKey();
-				TransportProperties p = e.getValue();
-				String address = p.get("address");
-				String uuid = p.get("uuid");
-				if(address != null && uuid != null) {
-					addresses.put(address, c);
-					uuids.put(c, uuid);
-				}
+			remote = callback.getRemoteProperties();
+		}
+		Map<String, ContactId> addresses = new HashMap<String, ContactId>();
+		Map<ContactId, String> uuids = new HashMap<ContactId, String>();
+		for(Entry<ContactId, TransportProperties> e : remote.entrySet()) {
+			ContactId c = e.getKey();
+			TransportProperties p = e.getValue();
+			String address = p.get("address");
+			String uuid = p.get("uuid");
+			if(address != null && uuid != null) {
+				addresses.put(address, c);
+				uuids.put(c, uuid);
 			}
 		}
-		BluetoothListener listener =
-			new BluetoothListener(discoveryAgent, addresses, uuids);
-		try {
-			synchronized(listener) {
+		ContactListener listener = new ContactListener(discoveryAgent,
+				addresses, uuids);
+		synchronized(discoveryLock) {
+			try {
 				discoveryAgent.startInquiry(DiscoveryAgent.GIAC, listener);
-				listener.wait();
+				return listener.waitForUrls();
+			} catch(BluetoothStateException e) {
+				if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
+				return Collections.emptyMap();
 			}
-		} catch(BluetoothStateException e) {
-			if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
-		} catch(InterruptedException ignored) {}
-		return listener.getUrls();
+		}
 	}
 
 	private StreamTransportConnection connect(ContactId c, String url) {
+		synchronized(this) {
+			if(!started) return null;
+		}
 		try {
-			synchronized(this) {
-				if(!started) return null;
-				Map<ContactId, TransportProperties> remote =
-					callback.getRemoteProperties();
-				if(!remote.containsKey(c)) return null;
-			}
 			StreamConnection s = (StreamConnection) Connector.open(url);
 			return new BluetoothTransportConnection(s);
 		} catch(IOException e) {
@@ -263,8 +264,137 @@ class BluetoothPlugin extends AbstractPlugin implements StreamPlugin {
 	}
 
 	public StreamTransportConnection createConnection(ContactId c) {
-		Map<ContactId, String> discovered = discover();
-		String url = discovered.get(c);
+		String url = discoverContactUrls().get(c);
 		return url == null ? null : connect(c, url);
 	}
+
+	public StreamTransportConnection sendInvitation(int code, long timeout) {
+		return createInvitationConnection(code, timeout);
+	}
+
+	public StreamTransportConnection acceptInvitation(int code, long timeout) {
+		return createInvitationConnection(code, timeout);
+	}
+
+	private StreamTransportConnection createInvitationConnection(int code,
+			long timeout) {
+		// The invitee's device may not be discoverable, so both parties must
+		// try to initiate connections
+		String uuid = convertInvitationCodeToUuid(code);
+		ConnectionCallback c = new ConnectionCallback(uuid, timeout);
+		startOutgoingInvitationThread(c);
+		startIncomingInvitationThread(c);
+		StreamConnection s = c.waitForConnection();
+		return s == null ? null : new BluetoothTransportConnection(s);
+	}
+
+	private String convertInvitationCodeToUuid(int code) {
+		byte[] b = new byte[16];
+		new Random(code).nextBytes(b);
+		return StringUtils.toHexString(b);
+	}
+
+	private void startOutgoingInvitationThread(final ConnectionCallback c) {
+		new Thread() {
+			@Override
+			public void run() {
+				createInvitationConnection(c);
+			}
+		}.start();
+	}
+
+	private void createInvitationConnection(ConnectionCallback c) {
+		DiscoveryAgent discoveryAgent;
+		synchronized(this) {
+			if(!started) return;
+			discoveryAgent = localDevice.getDiscoveryAgent();
+		}
+		// Try to discover the other party until the invitation times out
+		long end = System.currentTimeMillis() + c.getTimeout();
+		String url = null;
+		while(url == null && System.currentTimeMillis() < end) {
+			InvitationListener listener = new InvitationListener(discoveryAgent,
+					c.getUuid());
+			synchronized(discoveryLock) {
+				try {
+					discoveryAgent.startInquiry(DiscoveryAgent.GIAC, listener);
+					url = listener.waitForUrl();
+				} catch(BluetoothStateException e) {
+					if(LOG.isLoggable(Level.WARNING))
+						LOG.warning(e.getMessage());
+					return;
+				}
+			}
+			synchronized(this) {
+				if(!started) return;
+			}
+		}
+		if(url == null) return;
+		// Try to connect to the other party
+		try {
+			StreamConnection s = (StreamConnection) Connector.open(url);
+			c.addConnection(s);
+		} catch(IOException e) {
+			if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
+		}
+	}
+
+	private void startIncomingInvitationThread(final ConnectionCallback c) {
+		new Thread() {
+			@Override
+			public void run() {
+				bindInvitationSocket(c);
+			}
+		}.start();
+	}
+
+	private void bindInvitationSocket(ConnectionCallback c) {
+		synchronized(this) {
+			if(!started) return;
+			makeDeviceDiscoverable();
+		}
+		// Bind the socket
+		String url = "btspp://localhost:" + c.getUuid() + ";name=RFCOMM";
+		StreamConnectionNotifier scn;
+		try {
+			scn = (StreamConnectionNotifier) Connector.open(url);
+		} catch(IOException e) {
+			if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
+			return;
+		}
+		startInvitationAccepterThread(c, scn);
+		// Close the socket when the invitation times out
+		try {
+			Thread.sleep(c.getTimeout());
+			scn.close();
+		} catch(InterruptedException e) {
+			if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
+		} catch(IOException e) {
+			if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
+		}
+	}
+
+	private void startInvitationAccepterThread(final ConnectionCallback c,
+			final StreamConnectionNotifier scn) {
+		new Thread() {
+			@Override
+			public void run() {
+				acceptInvitationConnection(c, scn);
+			}
+		}.start();
+	}
+
+	private void acceptInvitationConnection(ConnectionCallback c,
+			StreamConnectionNotifier scn) {
+		synchronized(this) {
+			if(!started) return;
+		}
+		try {
+			StreamConnection s = scn.acceptAndOpen();
+			c.addConnection(s);
+		} catch(IOException e) {
+			// This is expected when the socket is closed
+			if(LOG.isLoggable(Level.INFO)) LOG.info(e.getMessage());
+		}
+	}
 }
diff --git a/components/net/sf/briar/plugins/bluetooth/ConnectionCallback.java b/components/net/sf/briar/plugins/bluetooth/ConnectionCallback.java
new file mode 100644
index 0000000000000000000000000000000000000000..fdcaebca11caeafab33553d5fab274e14e823cf6
--- /dev/null
+++ b/components/net/sf/briar/plugins/bluetooth/ConnectionCallback.java
@@ -0,0 +1,60 @@
+package net.sf.briar.plugins.bluetooth;
+
+import java.io.IOException;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import javax.microedition.io.StreamConnection;
+
+class ConnectionCallback {
+
+	private static final Logger LOG =
+		Logger.getLogger(ConnectionCallback.class.getName());
+
+	private final String uuid;
+	private final long timeout;
+	private final long end;
+
+	private StreamConnection connection = null; // Locking: this
+
+	ConnectionCallback(String uuid, long timeout) {
+		this.uuid = uuid;
+		this.timeout = timeout;
+		end = System.currentTimeMillis() + timeout;
+	}
+
+	String getUuid() {
+		return uuid;
+	}
+
+	long getTimeout() {
+		return timeout;
+	}
+
+	synchronized StreamConnection waitForConnection() {
+		long now = System.currentTimeMillis();
+		while(connection == null && now < end) {
+			try {
+				wait(end - now);
+			} catch(InterruptedException e) {
+				if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
+			}
+			now = System.currentTimeMillis();
+		}
+		return connection;
+	}
+
+	synchronized void addConnection(StreamConnection s) {
+		if(connection == null) {
+			connection = s;
+			notifyAll();
+		} else {
+			// Redundant connection
+			try {
+				s.close();
+			} catch(IOException e) {
+				if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
+			}
+		}
+	}
+}
diff --git a/components/net/sf/briar/plugins/bluetooth/BluetoothListener.java b/components/net/sf/briar/plugins/bluetooth/ContactListener.java
similarity index 64%
rename from components/net/sf/briar/plugins/bluetooth/BluetoothListener.java
rename to components/net/sf/briar/plugins/bluetooth/ContactListener.java
index 414abe7a6107505b20608742849ceb5fe77e7725..492fd4a1bc08718e09e173cc5d2c5addb080e078 100644
--- a/components/net/sf/briar/plugins/bluetooth/BluetoothListener.java
+++ b/components/net/sf/briar/plugins/bluetooth/ContactListener.java
@@ -1,6 +1,7 @@
 package net.sf.briar.plugins.bluetooth;
 
 import java.util.Collections;
+import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -8,6 +9,7 @@ import java.util.logging.Level;
 import java.util.logging.Logger;
 
 import javax.bluetooth.BluetoothStateException;
+import javax.bluetooth.DataElement;
 import javax.bluetooth.DeviceClass;
 import javax.bluetooth.DiscoveryAgent;
 import javax.bluetooth.DiscoveryListener;
@@ -17,12 +19,10 @@ import javax.bluetooth.UUID;
 
 import net.sf.briar.api.ContactId;
 
-class BluetoothListener implements DiscoveryListener {
+class ContactListener implements DiscoveryListener {
 
 	private static final Logger LOG =
-		Logger.getLogger(BluetoothListener.class.getName());
-
-	private static final int[] ATTRIBUTES = { 0x100 }; // Service name
+		Logger.getLogger(ContactListener.class.getName());
 
 	private final AtomicInteger searches = new AtomicInteger(1);
 	private final DiscoveryAgent discoveryAgent;
@@ -30,7 +30,9 @@ class BluetoothListener implements DiscoveryListener {
 	private final Map<ContactId, String> uuids;
 	private final Map<ContactId, String> urls;
 
-	BluetoothListener(DiscoveryAgent discoveryAgent,
+	private boolean finished = false; // Locking: this
+
+	ContactListener(DiscoveryAgent discoveryAgent,
 			Map<String, ContactId> addresses, Map<ContactId, String> uuids) {
 		this.discoveryAgent = discoveryAgent;
 		this.addresses = addresses;
@@ -38,7 +40,14 @@ class BluetoothListener implements DiscoveryListener {
 		urls = Collections.synchronizedMap(new HashMap<ContactId, String>());
 	}
 
-	public Map<ContactId, String> getUrls() {
+	public synchronized Map<ContactId, String> waitForUrls() {
+		while(!finished) {
+			try {
+				wait();
+			} catch(InterruptedException e) {
+				if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
+			}
+		}
 		return urls;
 	}
 
@@ -52,7 +61,7 @@ class BluetoothListener implements DiscoveryListener {
 		UUID[] uuids = new UUID[] { new UUID(uuid, false) };
 		// Try to discover the services associated with the UUID
 		try {
-			discoveryAgent.searchServices(ATTRIBUTES, uuids, device, this);
+			discoveryAgent.searchServices(null, uuids, device, this);
 		} catch(BluetoothStateException e) {
 			if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
 		}
@@ -62,6 +71,7 @@ class BluetoothListener implements DiscoveryListener {
 	public void inquiryCompleted(int discoveryType) {
 		if(searches.decrementAndGet() == 0) {
 			synchronized(this) {
+				finished = true;
 				notifyAll();
 			}
 		}
@@ -73,16 +83,33 @@ class BluetoothListener implements DiscoveryListener {
 			RemoteDevice device = record.getHostDevice();
 			ContactId c = addresses.get(device.getBluetoothAddress());
 			if(c == null) continue;
-			// Store the URL
-			String url = record.getConnectionURL(
+			// Do we have a UUID for this contact?
+			String uuid = uuids.get(c);
+			if(uuid == null) return;
+			// Does this service have a URL?
+			String serviceUrl = record.getConnectionURL(
 					ServiceRecord.NOAUTHENTICATE_NOENCRYPT, false);
-			if(url != null) urls.put(c, url);
+			if(serviceUrl == null) continue;
+			// Does this service have the UUID we're looking for?
+			DataElement classIds = record.getAttributeValue(0x1);
+			if(classIds == null) continue;
+			@SuppressWarnings("unchecked")
+			Enumeration<DataElement> e =
+				(Enumeration<DataElement>) classIds.getValue();
+			for(DataElement classId : Collections.list(e)) {
+				UUID serviceUuid = (UUID) classId.getValue();
+				if(uuid.equals(serviceUuid.toString())) {
+					// The UUID matches - store the URL
+					urls.put(c, serviceUrl);
+				}
+			}
 		}
 	}
 
 	public void serviceSearchCompleted(int transaction, int response) {
 		if(searches.decrementAndGet() == 0) {
 			synchronized(this) {
+				finished = true;
 				notifyAll();
 			}
 		}
diff --git a/components/net/sf/briar/plugins/bluetooth/InvitationListener.java b/components/net/sf/briar/plugins/bluetooth/InvitationListener.java
new file mode 100644
index 0000000000000000000000000000000000000000..96a94fc2091bb4b6147c37464052dcee1282880f
--- /dev/null
+++ b/components/net/sf/briar/plugins/bluetooth/InvitationListener.java
@@ -0,0 +1,101 @@
+package net.sf.briar.plugins.bluetooth;
+
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import javax.bluetooth.BluetoothStateException;
+import javax.bluetooth.DataElement;
+import javax.bluetooth.DeviceClass;
+import javax.bluetooth.DiscoveryAgent;
+import javax.bluetooth.DiscoveryListener;
+import javax.bluetooth.RemoteDevice;
+import javax.bluetooth.ServiceRecord;
+import javax.bluetooth.UUID;
+
+class InvitationListener implements DiscoveryListener {
+
+	private static final Logger LOG =
+		Logger.getLogger(InvitationListener.class.getName());
+
+	private final AtomicInteger searches = new AtomicInteger(1);
+	private final DiscoveryAgent discoveryAgent;
+	private final String uuid;
+
+	private String url = null; // Locking: this
+	private boolean finished = false; // Locking: this
+
+	InvitationListener(DiscoveryAgent discoveryAgent, String uuid) {
+		this.discoveryAgent = discoveryAgent;
+		this.uuid = uuid;
+	}
+
+	synchronized String waitForUrl() {
+		while(!finished) {
+			try {
+				wait();
+			} catch(InterruptedException e) {
+				if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
+			}
+		}
+		return url;
+	}
+
+	public void deviceDiscovered(RemoteDevice device, DeviceClass deviceClass) {
+		UUID[] uuids = new UUID[] { new UUID(uuid, false) };
+		// Try to discover the services associated with the UUID
+		try {
+			discoveryAgent.searchServices(null, uuids, device, this);
+			searches.incrementAndGet();
+		} catch(BluetoothStateException e) {
+			if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
+		}
+	}
+
+	public void inquiryCompleted(int discoveryType) {
+		if(searches.decrementAndGet() == 0) {
+			synchronized(this) {
+				finished = true;
+				notifyAll();
+			}
+		}
+	}
+
+	public void servicesDiscovered(int transaction, ServiceRecord[] services) {
+		for(ServiceRecord record : services) {
+			// Does this service have a URL?
+			String serviceUrl = record.getConnectionURL(
+					ServiceRecord.NOAUTHENTICATE_NOENCRYPT, false);
+			if(serviceUrl == null) continue;
+			// Does this service have the UUID we're looking for?
+			DataElement classIds = record.getAttributeValue(0x1);
+			if(classIds == null) continue;
+			@SuppressWarnings("unchecked")
+			Enumeration<DataElement> e =
+				(Enumeration<DataElement>) classIds.getValue();
+			for(DataElement classId : Collections.list(e)) {
+				UUID serviceUuid = (UUID) classId.getValue();
+				if(uuid.equals(serviceUuid.toString())) {
+					// The UUID matches - store the URL
+					synchronized(this) {
+						url = serviceUrl;
+						finished = true;
+						notifyAll();
+					}
+					return;
+				}
+			}
+		}
+	}
+
+	public void serviceSearchCompleted(int transaction, int response) {
+		if(searches.decrementAndGet() == 0) {
+			synchronized(this) {
+				finished = true;
+				notifyAll();
+			}
+		}
+	}
+}
diff --git a/components/net/sf/briar/plugins/file/FileListener.java b/components/net/sf/briar/plugins/file/FileListener.java
new file mode 100644
index 0000000000000000000000000000000000000000..d71de06e4833296d260057dd200e36f9554c8677
--- /dev/null
+++ b/components/net/sf/briar/plugins/file/FileListener.java
@@ -0,0 +1,41 @@
+package net.sf.briar.plugins.file;
+
+import java.io.File;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+class FileListener {
+
+	private static final Logger LOG =
+		Logger.getLogger(FileListener.class.getName());
+
+	private final String filename;
+	private final long end;
+
+	private File file = null; // Locking: this
+
+	FileListener(String filename, long timeout) {
+		this.filename = filename;
+		end = System.currentTimeMillis() + timeout;
+	}
+
+	synchronized File waitForFile() {
+		long now = System.currentTimeMillis();
+		while(file == null && now < end) {
+			try {
+				wait(end - now);
+			} catch(InterruptedException e) {
+				if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
+			}
+			now = System.currentTimeMillis();
+		}
+		return file;
+	}
+
+	synchronized void addFile(File f) {
+		if(filename.equals(f.getName())) {
+			file = f;
+			notifyAll();
+		}
+	}
+}
diff --git a/components/net/sf/briar/plugins/file/FilePlugin.java b/components/net/sf/briar/plugins/file/FilePlugin.java
index b97d532be362e8b56e57b55c0df45e1d7778e9e5..3e9f080300d4eec6bd151e036cd92c643cc05ff4 100644
--- a/components/net/sf/briar/plugins/file/FilePlugin.java
+++ b/components/net/sf/briar/plugins/file/FilePlugin.java
@@ -5,13 +5,14 @@ import java.io.FileInputStream;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.OutputStream;
+import java.util.Collection;
 import java.util.concurrent.Executor;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
 import net.sf.briar.api.ContactId;
-import net.sf.briar.api.plugins.BatchPluginCallback;
 import net.sf.briar.api.plugins.BatchPlugin;
+import net.sf.briar.api.plugins.BatchPluginCallback;
 import net.sf.briar.api.transport.BatchTransportReader;
 import net.sf.briar.api.transport.BatchTransportWriter;
 import net.sf.briar.api.transport.TransportConstants;
@@ -26,7 +27,11 @@ abstract class FilePlugin extends AbstractPlugin implements BatchPlugin {
 
 	protected final BatchPluginCallback callback;
 
+	private final Object listenerLock = new Object();
+	private FileListener listener = null; // Locking: listenerLock
+
 	protected abstract File chooseOutputDirectory();
+	protected abstract Collection<File> findFilesByName(String filename);
 	protected abstract void writerFinished(File f);
 	protected abstract void readerFinished(File f);
 
@@ -40,12 +45,28 @@ abstract class FilePlugin extends AbstractPlugin implements BatchPlugin {
 	}
 
 	public BatchTransportWriter createWriter(ContactId c) {
+		return createWriter(createConnectionFilename());
+	}
+
+	private String createConnectionFilename() {
+		StringBuilder s = new StringBuilder(12);
+		for(int i = 0; i < 8; i++) s.append((char) ('a' + Math.random() * 26));
+		s.append(".dat");
+		return s.toString();
+	}
+
+	// Package access for testing
+	boolean isPossibleConnectionFilename(String filename) {
+		return filename.toLowerCase().matches("[a-z]{8}\\.dat");
+	}
+
+	private BatchTransportWriter createWriter(String filename) {
 		synchronized(this) {
 			if(!started) return null;
 		}
 		File dir = chooseOutputDirectory();
 		if(dir == null || !dir.exists() || !dir.isDirectory()) return null;
-		File f = new File(dir, createFilename());
+		File f = new File(dir, filename);
 		try {
 			long capacity = getCapacity(dir.getPath());
 			if(capacity < TransportConstants.MIN_CONNECTION_LENGTH) return null;
@@ -58,13 +79,6 @@ abstract class FilePlugin extends AbstractPlugin implements BatchPlugin {
 		}
 	}
 
-	private String createFilename() {
-		StringBuilder s = new StringBuilder(12);
-		for(int i = 0; i < 8; i++) s.append((char) ('a' + Math.random() * 26));
-		s.append(".dat");
-		return s.toString();
-	}
-
 	private long getCapacity(String path) throws IOException {
 		return FileSystemUtils.freeSpaceKb(path) * 1024L;
 	}
@@ -74,9 +88,60 @@ abstract class FilePlugin extends AbstractPlugin implements BatchPlugin {
 		executor.execute(new ReaderCreator(f));
 	}
 
+	public BatchTransportWriter sendInvitation(int code, long timeout) {
+		return createWriter(createInvitationFilename(code, false));
+	}
+
+	public BatchTransportReader acceptInvitation(int code, long timeout) {
+		String filename = createInvitationFilename(code, false);
+		return createInvitationReader(filename, timeout);
+	}
+
+	public BatchTransportWriter sendInvitationResponse(int code, long timeout) {
+		return createWriter(createInvitationFilename(code, true));
+	}
+
+	public BatchTransportReader acceptInvitationResponse(int code,
+			long timeout) {
+		String filename = createInvitationFilename(code, true);
+		return createInvitationReader(filename, timeout);
+	}
+
+	private BatchTransportReader createInvitationReader(String filename,
+			long timeout) {
+		Collection<File> files;
+		synchronized(listenerLock) {
+			// Find any matching files that have already arrived
+			files = findFilesByName(filename);
+			if(files.isEmpty()) {
+				// Wait for a matching file to arrive
+				listener = new FileListener(filename, timeout);
+				File f = listener.waitForFile();
+				if(f != null) files.add(f);
+				listener = null;
+			}
+		}
+		// Return the first match that can be opened
+		for(File f : files) {
+			try {
+				FileInputStream in = new FileInputStream(f);
+				return new FileTransportReader(f, in, FilePlugin.this);
+			} catch(IOException e) {
+				if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
+			}
+		}
+		return null;
+	}
+
+	private String createInvitationFilename(int code, boolean response) {
+		assert code >= 0;
+		assert code < 10 * 1000 * 1000;
+		return String.format("%c%7d.dat", response ? 'b' : 'a', code);
+	}
+
 	// Package access for testing
-	boolean isPossibleConnectionFilename(String filename) {
-		return filename.toLowerCase().matches("[a-z]{8}\\.dat");
+	boolean isPossibleInvitationFilename(String filename) {
+		return filename.toLowerCase().matches("[ab][0-9]{7}.dat");
 	}
 
 	private class ReaderCreator implements Runnable {
@@ -88,14 +153,21 @@ abstract class FilePlugin extends AbstractPlugin implements BatchPlugin {
 		}
 
 		public void run() {
-			if(!isPossibleConnectionFilename(f.getName())) return;
-			if(f.length() < TransportConstants.MIN_CONNECTION_LENGTH) return;
-			try {
-				FileInputStream in = new FileInputStream(f);
-				callback.readerCreated(new FileTransportReader(f, in,
-						FilePlugin.this));
-			} catch(IOException e) {
-				if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
+			String filename = f.getName();
+			if(isPossibleInvitationFilename(filename)) {
+				synchronized(listenerLock) {
+					if(listener != null) listener.addFile(f);
+				}
+			}
+			if(isPossibleConnectionFilename(f.getName())) {
+				try {
+					FileInputStream in = new FileInputStream(f);
+					callback.readerCreated(new FileTransportReader(f, in,
+							FilePlugin.this));
+				} catch(IOException e) {
+					if(LOG.isLoggable(Level.WARNING))
+						LOG.warning(e.getMessage());
+				}
 			}
 		}
 	}
diff --git a/components/net/sf/briar/plugins/file/PollingRemovableDriveMonitor.java b/components/net/sf/briar/plugins/file/PollingRemovableDriveMonitor.java
index 853899befc1d5855ff29146878f21192840c5567..0d582bc3505d3475981a10c483b870269214e907 100644
--- a/components/net/sf/briar/plugins/file/PollingRemovableDriveMonitor.java
+++ b/components/net/sf/briar/plugins/file/PollingRemovableDriveMonitor.java
@@ -3,9 +3,14 @@ package net.sf.briar.plugins.file;
 import java.io.File;
 import java.io.IOException;
 import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
 
 class PollingRemovableDriveMonitor implements RemovableDriveMonitor, Runnable {
 
+	private static final Logger LOG =
+		Logger.getLogger(PollingRemovableDriveMonitor.class.getName());
+
 	private final RemovableDriveFinder finder;
 	private final long pollingInterval;
 	private final Object pollingLock = new Object();
@@ -47,7 +52,10 @@ class PollingRemovableDriveMonitor implements RemovableDriveMonitor, Runnable {
 				synchronized(pollingLock) {
 					try {
 						pollingLock.wait(pollingInterval);
-					} catch(InterruptedException ignored) {}
+					} catch(InterruptedException e) {
+						if(LOG.isLoggable(Level.WARNING))
+							LOG.warning(e.getMessage());
+					}
 				}
 				if(!running) return;
 				List<File> newDrives = finder.findRemovableDrives();
diff --git a/components/net/sf/briar/plugins/file/RemovableDrivePlugin.java b/components/net/sf/briar/plugins/file/RemovableDrivePlugin.java
index 7aafe10e7e918699e07b4ae2e038cb8a916d0357..9a2633fdc9716cdb76ac96434a8cfd83f7a362f2 100644
--- a/components/net/sf/briar/plugins/file/RemovableDrivePlugin.java
+++ b/components/net/sf/briar/plugins/file/RemovableDrivePlugin.java
@@ -2,6 +2,8 @@ package net.sf.briar.plugins.file;
 
 import java.io.File;
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
 import java.util.concurrent.Executor;
 import java.util.logging.Level;
@@ -85,8 +87,29 @@ implements RemovableDriveMonitor.Callback {
 		callback.showMessage("REMOVABLE_DRIVE_WRITE_FINISHED");
 	}
 
+	@Override
+	protected Collection<File> findFilesByName(String filename) {
+		Collection<File> matches = new ArrayList<File>();
+		try {
+			for(File drive : finder.findRemovableDrives()) {
+				File[] files = drive.listFiles();
+				if(files != null) {
+					for(File f : files) {
+						if(f.isFile() && filename.equals(f.getName()))
+							matches.add(f);
+					}
+				}
+			}
+		} catch(IOException e) {
+			if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
+		}
+		return matches;
+	}
+
 	public void driveInserted(File root) {
 		File[] files = root.listFiles();
-		if(files != null) for(File f : files) createReaderFromFile(f);
+		if(files != null) {
+			for(File f : files) if(f.isFile()) createReaderFromFile(f);
+		}
 	}
 }
diff --git a/components/net/sf/briar/plugins/socket/SimpleSocketPlugin.java b/components/net/sf/briar/plugins/socket/SimpleSocketPlugin.java
index 8c57f555de058e8ee716a92ee95a0eb13fd21502..09d2e96fb9add3f858325f4f3527fe692560cf6b 100644
--- a/components/net/sf/briar/plugins/socket/SimpleSocketPlugin.java
+++ b/components/net/sf/briar/plugins/socket/SimpleSocketPlugin.java
@@ -1,6 +1,7 @@
 package net.sf.briar.plugins.socket;
 
 import java.io.IOException;
+import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.ServerSocket;
 import java.net.Socket;
@@ -11,6 +12,7 @@ import net.sf.briar.api.ContactId;
 import net.sf.briar.api.TransportId;
 import net.sf.briar.api.TransportProperties;
 import net.sf.briar.api.plugins.StreamPluginCallback;
+import net.sf.briar.api.transport.StreamTransportConnection;
 
 class SimpleSocketPlugin extends SocketPlugin {
 
@@ -67,7 +69,8 @@ class SimpleSocketPlugin extends SocketPlugin {
 			TransportProperties p) {
 		assert started;
 		assert p != null;
-		String host = p.get("host");
+		String host = p.get("external");
+		if(host == null) host = p.get("internal");
 		String portString = p.get("port");
 		if(host == null || portString == null) return null;
 		int port;
@@ -85,12 +88,22 @@ class SimpleSocketPlugin extends SocketPlugin {
 		if(!(s instanceof InetSocketAddress))
 			throw new IllegalArgumentException();
 		InetSocketAddress i = (InetSocketAddress) s;
-		String host = i.getAddress().getHostAddress();
-		String port = String.valueOf(i.getPort());
-		// FIXME: Special handling for private IP addresses?
+		InetAddress addr = i.getAddress();
 		TransportProperties p = callback.getLocalProperties();
-		p.put("host", host);
-		p.put("port", port);
+		if(addr.isLinkLocalAddress() || addr.isSiteLocalAddress())
+			p.put("internal", addr.getHostAddress());
+		else p.put("external", addr.getHostAddress());
+		p.put("port", String.valueOf(i.getPort()));
 		callback.setLocalProperties(p);
 	}
+
+	public StreamTransportConnection sendInvitation(int code, long timeout) {
+		// FIXME
+		return null;
+	}
+
+	public StreamTransportConnection acceptInvitation(int code, long timeout) {
+		// FIXME
+		return null;
+	}
 }
diff --git a/components/net/sf/briar/plugins/socket/SocketPlugin.java b/components/net/sf/briar/plugins/socket/SocketPlugin.java
index f90852374a39d36851eaaae084227be40b62a396..9c9fe08698484e51cd03cb976298651d87bf0212 100644
--- a/components/net/sf/briar/plugins/socket/SocketPlugin.java
+++ b/components/net/sf/briar/plugins/socket/SocketPlugin.java
@@ -21,8 +21,7 @@ abstract class SocketPlugin extends AbstractPlugin implements StreamPlugin {
 
 	protected final StreamPluginCallback callback;
 
-	// This field must only be accessed with this's lock held
-	protected ServerSocket socket = null;
+	protected ServerSocket socket = null; // Locking: this
 
 	protected abstract void setLocalSocketAddress(SocketAddress s);
 
@@ -85,10 +84,10 @@ abstract class SocketPlugin extends AbstractPlugin implements StreamPlugin {
 			socket = ss;
 			setLocalSocketAddress(ss.getLocalSocketAddress());
 		}
-		startListener();
+		startListenerThread();
 	}
 
-	private void startListener() {
+	private void startListenerThread() {
 		new Thread() {
 			@Override
 			public void run() {
@@ -108,7 +107,8 @@ abstract class SocketPlugin extends AbstractPlugin implements StreamPlugin {
 			try {
 				s = ss.accept();
 			} catch(IOException e) {
-				if(LOG.isLoggable(Level.WARNING)) LOG.warning(e.getMessage());
+				// This is expected when the socket is closed
+				if(LOG.isLoggable(Level.INFO)) LOG.info(e.getMessage());
 				return;
 			}
 			SocketTransportConnection conn = new SocketTransportConnection(s);
diff --git a/components/net/sf/briar/transport/FrameScheduler.java b/components/net/sf/briar/transport/FrameScheduler.java
index 955f6ab89790dfd77d1bea18a9cb88627a1658c3..94bc075549199b1e6e1e435f817bfadcfacc452f 100644
--- a/components/net/sf/briar/transport/FrameScheduler.java
+++ b/components/net/sf/briar/transport/FrameScheduler.java
@@ -2,6 +2,9 @@ package net.sf.briar.transport;
 
 import static net.sf.briar.api.transport.TransportConstants.MAX_FRAME_LENGTH;
 
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
 /**
  * A thread that calls the writeFullFrame() method of a PaddedConnectionWriter
  * at regular intervals. The interval between calls is determined by a target
@@ -10,6 +13,9 @@ import static net.sf.briar.api.transport.TransportConstants.MAX_FRAME_LENGTH;
  */
 class FrameScheduler extends Thread {
 
+	private static final Logger LOG =
+		Logger.getLogger(FrameScheduler.class.getName());
+
 	private final PaddedConnectionWriter writer;
 	private final int millisPerFrame;
 
@@ -27,7 +33,10 @@ class FrameScheduler extends Thread {
 			if(nextCall > now) {
 				try {
 					Thread.sleep(nextCall - now);
-				} catch(InterruptedException ignored) {}
+				} catch(InterruptedException e) {
+					if(LOG.isLoggable(Level.WARNING))
+						LOG.warning(e.getMessage());
+				}
 			}
 			lastCall = System.currentTimeMillis();
 			if(!writer.writeFullFrame()) return;
diff --git a/components/net/sf/briar/transport/stream/StreamConnection.java b/components/net/sf/briar/transport/stream/StreamConnection.java
index 419909437688c580aad2a5ed859c465be93d4936..51c2e1ee133c329c0ac24c6a509c8e08ea533318 100644
--- a/components/net/sf/briar/transport/stream/StreamConnection.java
+++ b/components/net/sf/briar/transport/stream/StreamConnection.java
@@ -219,7 +219,10 @@ abstract class StreamConnection implements DatabaseListener {
 						while(writerFlags == 0) {
 							try {
 								wait();
-							} catch(InterruptedException ignored) {}
+							} catch(InterruptedException e) {
+								if(LOG.isLoggable(Level.WARNING))
+									LOG.warning(e.getMessage());
+							}
 						}
 						flags = writerFlags;
 						writerFlags = 0;
@@ -256,7 +259,10 @@ abstract class StreamConnection implements DatabaseListener {
 						while(writerFlags == 0) {
 							try {
 								wait();
-							} catch(InterruptedException ignored) {}
+							} catch(InterruptedException e) {
+								if(LOG.isLoggable(Level.WARNING))
+									LOG.warning(e.getMessage());
+							}
 						}
 						flags = writerFlags;
 						writerFlags = 0;
diff --git a/test/net/sf/briar/LockFairnessTest.java b/test/net/sf/briar/LockFairnessTest.java
index 4860eba5b60537c719be5c27241f4cb856707587..081c89fc71911198a4a43814bdc1e746bd4095b9 100644
--- a/test/net/sf/briar/LockFairnessTest.java
+++ b/test/net/sf/briar/LockFairnessTest.java
@@ -76,7 +76,8 @@ public class LockFairnessTest extends TestCase {
 			try {
 				Thread.sleep(sleepTime);
 				finished.add(this);
-			} catch(InterruptedException ignored) {
+			} catch(InterruptedException e) {
+				e.printStackTrace();
 			} finally {
 				lock.readLock().unlock();
 			}
@@ -99,7 +100,8 @@ public class LockFairnessTest extends TestCase {
 			try {
 				Thread.sleep(sleepTime);
 				finished.add(this);
-			} catch(InterruptedException ignored) {
+			} catch(InterruptedException e) {
+				e.printStackTrace();
 			} finally {
 				lock.writeLock().unlock();
 			}
diff --git a/test/net/sf/briar/db/H2DatabaseTest.java b/test/net/sf/briar/db/H2DatabaseTest.java
index e2ac13f2c9bc5fefb59373e926726368d50ded24..904b04714a8bdb426d952c47d75a0bb0e0abb8ca 100644
--- a/test/net/sf/briar/db/H2DatabaseTest.java
+++ b/test/net/sf/briar/db/H2DatabaseTest.java
@@ -737,9 +737,7 @@ public class H2DatabaseTest extends TestCase {
 		for(int i = 0; i < ids.length; i++) {
 			db.addOutstandingBatch(txn, contactId, ids[i],
 					Collections.<MessageId>emptySet());
-			try {
-				Thread.sleep(5);
-			} catch(InterruptedException ignored) {}
+			Thread.sleep(5);
 		}
 
 		// The contact acks the batches in reverse order. The first
@@ -779,9 +777,7 @@ public class H2DatabaseTest extends TestCase {
 		for(int i = 0; i < ids.length; i++) {
 			db.addOutstandingBatch(txn, contactId, ids[i],
 					Collections.<MessageId>emptySet());
-			try {
-				Thread.sleep(5);
-			} catch(InterruptedException ignored) {}
+			Thread.sleep(5);
 		}
 
 		// The contact acks the batches in the order they were sent - nothing
@@ -946,9 +942,7 @@ public class H2DatabaseTest extends TestCase {
 		};
 		t.start();
 		// Do whatever the transaction needs to do
-		try {
-			Thread.sleep(10);
-		} catch(InterruptedException ignored) {}
+		Thread.sleep(10);
 		transactionFinished.set(true);
 		// Commit the transaction
 		db.commitTransaction(txn);
@@ -981,9 +975,7 @@ public class H2DatabaseTest extends TestCase {
 		};
 		t.start();
 		// Do whatever the transaction needs to do
-		try {
-			Thread.sleep(10);
-		} catch(InterruptedException ignored) {}
+		Thread.sleep(10);
 		transactionFinished.set(true);
 		// Abort the transaction
 		db.abortTransaction(txn);
diff --git a/test/net/sf/briar/plugins/bluetooth/BluetoothClientTest.java b/test/net/sf/briar/plugins/bluetooth/BluetoothClientTest.java
index fdb352325d7f91b24e5de41e1a075e89f7b9c482..e53cb47077212c223880ad91c279b3d7342e2b3b 100644
--- a/test/net/sf/briar/plugins/bluetooth/BluetoothClientTest.java
+++ b/test/net/sf/briar/plugins/bluetooth/BluetoothClientTest.java
@@ -1,65 +1,81 @@
 package net.sf.briar.plugins.bluetooth;
 
-import java.io.PrintStream;
+import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.Scanner;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
 
 import net.sf.briar.api.ContactId;
 import net.sf.briar.api.TransportConfig;
 import net.sf.briar.api.TransportProperties;
 import net.sf.briar.api.plugins.StreamPluginCallback;
 import net.sf.briar.api.transport.StreamTransportConnection;
-import net.sf.briar.plugins.ImmediateExecutor;
 
 // This is not a JUnit test - it has to be run manually while the server test
 // is running on another machine
-public class BluetoothClientTest {
+public class BluetoothClientTest extends BluetoothTest {
 
-	public static final String RESPONSE = "Carrots!";
+	private final String serverAddress;
 
-	public static void main(String[] args) throws Exception {
-		if(args.length != 1) {
-			System.err.println("Please specify the server's Bluetooth address");
-			System.exit(1);
-		}
+	BluetoothClientTest(String serverAddress) {
+		this.serverAddress = serverAddress;
+	}
+
+	void run() throws IOException {
 		ContactId contactId = new ContactId(0);
 		ClientCallback callback = new ClientCallback();
 		// Store the server's Bluetooth address and UUID
 		TransportProperties p = new TransportProperties();
-		p.put("address", args[0]);
+		p.put("address", serverAddress);
 		p.put("uuid", BluetoothServerTest.UUID);
 		callback.remote.put(contactId, p);
 		// Create the plugin
-		BluetoothPlugin plugin =
-			new BluetoothPlugin(new ImmediateExecutor(), callback, 0L);
+		Executor e = Executors.newCachedThreadPool();
+		BluetoothPlugin plugin = new BluetoothPlugin(e, callback, 0L);
 		// Start the plugin
 		System.out.println("Starting plugin");
 		plugin.start();
 		// Try to connect to the server
 		System.out.println("Creating connection");
-		StreamTransportConnection conn = plugin.createConnection(contactId);
-		if(conn == null) {
+		StreamTransportConnection s = plugin.createConnection(contactId);
+		if(s == null) {
+			System.out.println("Connection failed");
+		} else {
+			System.out.println("Connection created");
+			receiveChallengeAndSendResponse(s);
+		}
+		// Try to send an invitation
+		System.out.println("Sending invitation");
+		s = plugin.sendInvitation(123, INVITATION_TIMEOUT);
+		if(s == null) {
 			System.out.println("Connection failed");
 		} else {
 			System.out.println("Connection created");
-			Scanner in = new Scanner(conn.getInputStream());
-			String challenge = in.nextLine();
-			System.out.println("Received challenge: " + challenge);
-			if(BluetoothServerTest.CHALLENGE.equals(challenge)) {
-				PrintStream out = new PrintStream(conn.getOutputStream());
-				out.println(RESPONSE);
-				System.out.println("Sent response: " + RESPONSE);
-			} else {
-				System.out.println("Incorrect challenge");
-			}
-			conn.dispose(true);
+			receiveChallengeAndSendResponse(s);
+		}
+		// Try to accept an invitation
+		System.out.println("Accepting invitation");
+		s = plugin.acceptInvitation(456, INVITATION_TIMEOUT);
+		if(s == null) {
+			System.out.println("Connection failed");
+		} else {
+			System.out.println("Connection created");
+			sendChallengeAndReceiveResponse(s);
 		}
 		// Stop the plugin
 		System.out.println("Stopping plugin");
 		plugin.stop();
 	}
 
+	public static void main(String[] args) throws Exception {
+		if(args.length != 1) {
+			System.err.println("Please specify the server's Bluetooth address");
+			System.exit(1);
+		}
+		new BluetoothClientTest(args[0]).run();
+	}
+
 	private static class ClientCallback implements StreamPluginCallback {
 
 		private TransportConfig config = new TransportConfig();
diff --git a/test/net/sf/briar/plugins/bluetooth/BluetoothServerTest.java b/test/net/sf/briar/plugins/bluetooth/BluetoothServerTest.java
index ec9a54bb7ace051b2b7373a70eb94c1fa19d1889..ab7ed23ab947da5aa482023b6d568c4a95bb3345 100644
--- a/test/net/sf/briar/plugins/bluetooth/BluetoothServerTest.java
+++ b/test/net/sf/briar/plugins/bluetooth/BluetoothServerTest.java
@@ -1,32 +1,27 @@
 package net.sf.briar.plugins.bluetooth;
 
-import java.io.IOException;
-import java.io.PrintStream;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.Scanner;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
 
 import net.sf.briar.api.ContactId;
 import net.sf.briar.api.TransportConfig;
 import net.sf.briar.api.TransportProperties;
 import net.sf.briar.api.plugins.StreamPluginCallback;
 import net.sf.briar.api.transport.StreamTransportConnection;
-import net.sf.briar.plugins.ImmediateExecutor;
 
 //This is not a JUnit test - it has to be run manually while the server test
 //is running on another machine
-public class BluetoothServerTest {
+public class BluetoothServerTest extends BluetoothTest {
 
-	public static final String UUID = "CABBA6E5CABBA6E5CABBA6E5CABBA6E5";
-	public static final String CHALLENGE = "Potatoes!";
-
-	public static void main(String[] args) throws Exception {
+	void run() throws Exception {
 		ServerCallback callback = new ServerCallback();
 		// Store the UUID
 		callback.config.put("uuid", UUID);
 		// Create the plugin
-		BluetoothPlugin plugin =
-			new BluetoothPlugin(new ImmediateExecutor(), callback, 0L);
+		Executor e = Executors.newCachedThreadPool();
+		BluetoothPlugin plugin = new BluetoothPlugin(e, callback, 0L);
 		// Start the plugin
 		System.out.println("Starting plugin");
 		plugin.start();
@@ -35,12 +30,35 @@ public class BluetoothServerTest {
 		synchronized(callback) {
 			callback.wait();
 		}
+		// Try to accept an invitation
+		System.out.println("Accepting invitation");
+		StreamTransportConnection s = plugin.acceptInvitation(123,
+				INVITATION_TIMEOUT);
+		if(s == null) {
+			System.out.println("Connection failed");
+		} else {
+			System.out.println("Connection created");
+			sendChallengeAndReceiveResponse(s);
+		}
+		// Try to send an invitation
+		System.out.println("Sending invitation");
+		s = plugin.sendInvitation(456, INVITATION_TIMEOUT);
+		if(s == null) {
+			System.out.println("Connection failed");
+		} else {
+			System.out.println("Connection created");
+			receiveChallengeAndSendResponse(s);
+		}
 		// Stop the plugin
 		System.out.println("Stopping plugin");
 		plugin.stop();
 	}
 
-	private static class ServerCallback implements StreamPluginCallback {
+	public static void main(String[] args) throws Exception {
+		new BluetoothServerTest().run();
+	}
+
+	private class ServerCallback implements StreamPluginCallback {
 
 		private TransportConfig config = new TransportConfig();
 		private TransportProperties local = new TransportProperties();
@@ -77,24 +95,9 @@ public class BluetoothServerTest {
 
 		public void showMessage(String... message) {}
 
-		public void incomingConnectionCreated(StreamTransportConnection conn) {
+		public void incomingConnectionCreated(StreamTransportConnection s) {
 			System.out.println("Connection received");
-			try {
-				PrintStream out = new PrintStream(conn.getOutputStream());
-				out.println(CHALLENGE);
-				System.out.println("Sent challenge: " + CHALLENGE);
-				Scanner in = new Scanner(conn.getInputStream());
-				String response = in.nextLine();
-				System.out.println("Received response: " + response);
-				if(BluetoothClientTest.RESPONSE.equals(response)) {
-					System.out.println("Correct response");
-				} else {
-					System.out.println("Incorrect response");
-				}
-				conn.dispose(true);
-			} catch(IOException e) {
-				e.printStackTrace();
-			}
+			sendChallengeAndReceiveResponse(s);
 			synchronized(this) {
 				notifyAll();
 			}
diff --git a/test/net/sf/briar/plugins/bluetooth/BluetoothTest.java b/test/net/sf/briar/plugins/bluetooth/BluetoothTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..ba8452cc3c2e7a59ad62f84193cb36e9bb76bfea
--- /dev/null
+++ b/test/net/sf/briar/plugins/bluetooth/BluetoothTest.java
@@ -0,0 +1,54 @@
+package net.sf.briar.plugins.bluetooth;
+
+import java.io.IOException;
+import java.io.PrintStream;
+import java.util.Scanner;
+
+import net.sf.briar.api.transport.StreamTransportConnection;
+
+abstract class BluetoothTest {
+
+	protected static final String UUID = "CABBA6E5CABBA6E5CABBA6E5CABBA6E5";
+	protected static final String CHALLENGE = "Carrots!";
+	protected static final String RESPONSE = "Potatoes!";
+	protected static final long INVITATION_TIMEOUT = 30 * 1000;
+
+	void sendChallengeAndReceiveResponse(StreamTransportConnection s) {
+		try {
+			PrintStream out = new PrintStream(s.getOutputStream());
+			out.println(CHALLENGE);
+			System.out.println("Sent challenge: " + CHALLENGE);
+			Scanner in = new Scanner(s.getInputStream());
+			String response = in.nextLine();
+			System.out.println("Received response: " + response);
+			if(BluetoothClientTest.RESPONSE.equals(response)) {
+				System.out.println("Correct response");
+			} else {
+				System.out.println("Incorrect response");
+			}
+			s.dispose(true);
+		} catch(IOException e) {
+			e.printStackTrace();
+			s.dispose(false);
+		}
+	}
+
+	void receiveChallengeAndSendResponse(StreamTransportConnection s) {
+		try {
+			Scanner in = new Scanner(s.getInputStream());
+			String challenge = in.nextLine();
+			System.out.println("Received challenge: " + challenge);
+			if(BluetoothServerTest.CHALLENGE.equals(challenge)) {
+				PrintStream out = new PrintStream(s.getOutputStream());
+				out.println(RESPONSE);
+				System.out.println("Sent response: " + RESPONSE);
+			} else {
+				System.out.println("Incorrect challenge");
+			}
+			s.dispose(true);
+		} catch(IOException e) {
+			e.printStackTrace();
+			s.dispose(false);
+		}
+	}
+}
\ No newline at end of file
diff --git a/test/net/sf/briar/plugins/file/RemovableDrivePluginTest.java b/test/net/sf/briar/plugins/file/RemovableDrivePluginTest.java
index 05ec383485485d9f420a734224bd5abda6b9b11e..3cf909647896322670a2be6a81d57cc0a18b4bb9 100644
--- a/test/net/sf/briar/plugins/file/RemovableDrivePluginTest.java
+++ b/test/net/sf/briar/plugins/file/RemovableDrivePluginTest.java
@@ -339,35 +339,6 @@ public class RemovableDrivePluginTest extends TestCase {
 		context.assertIsSatisfied();
 	}
 
-	@Test
-	public void testSmallFileIsIgnored() throws Exception {
-		Mockery context = new Mockery();
-		final BatchPluginCallback callback =
-			context.mock(BatchPluginCallback.class);
-		final RemovableDriveFinder finder =
-			context.mock(RemovableDriveFinder.class);
-		final RemovableDriveMonitor monitor =
-			context.mock(RemovableDriveMonitor.class);
-
-		context.checking(new Expectations() {{
-			oneOf(monitor).start(with(any(Callback.class)));
-		}});
-
-		RemovableDrivePlugin plugin = new RemovableDrivePlugin(
-				new ImmediateExecutor(), callback, finder, monitor);
-		plugin.start();
-
-		File f = new File(testDir, "abcdefgh.dat");
-		OutputStream out = new FileOutputStream(f);
-		out.write(new byte[TransportConstants.MIN_CONNECTION_LENGTH - 1]);
-		out.flush();
-		out.close();
-		assertEquals(TransportConstants.MIN_CONNECTION_LENGTH - 1, f.length());
-		plugin.driveInserted(testDir);
-
-		context.assertIsSatisfied();
-	}
-
 	@Test
 	public void testReaderIsCreated() throws Exception {
 		Mockery context = new Mockery();
diff --git a/test/net/sf/briar/plugins/socket/SimpleSocketPluginTest.java b/test/net/sf/briar/plugins/socket/SimpleSocketPluginTest.java
index 28e590fc5e0646e03212236b48eea9765b55e2d9..4faf6e16aa9e364334c0a6d27ed45e36dbfedda0 100644
--- a/test/net/sf/briar/plugins/socket/SimpleSocketPluginTest.java
+++ b/test/net/sf/briar/plugins/socket/SimpleSocketPluginTest.java
@@ -27,13 +27,13 @@ public class SimpleSocketPluginTest extends TestCase {
 	@Test
 	public void testIncomingConnection() throws Exception {
 		StreamCallback callback = new StreamCallback();
-		callback.local.put("host", "127.0.0.1");
+		callback.local.put("internal", "127.0.0.1");
 		callback.local.put("port", "0");
 		SimpleSocketPlugin plugin =
 			new SimpleSocketPlugin(new ImmediateExecutor(), callback, 0L);
 		plugin.start();
 		// The plugin should have bound a socket and stored the port number
-		String host = callback.local.get("host");
+		String host = callback.local.get("internal");
 		assertNotNull(host);
 		assertEquals("127.0.0.1", host);
 		String portString = callback.local.get("port");
@@ -84,7 +84,7 @@ public class SimpleSocketPluginTest extends TestCase {
 		}.start();
 		// Tell the plugin about the port
 		TransportProperties p = new TransportProperties();
-		p.put("host", "127.0.0.1");
+		p.put("internal", "127.0.0.1");
 		p.put("port", String.valueOf(port));
 		callback.remote.put(contactId, p);
 		// Connect to the port