diff --git a/briar-tests/.classpath b/briar-tests/.classpath
new file mode 100644
index 0000000000000000000000000000000000000000..5e92e5c69cbb1d8a9a268fc88ad0dfab83671e97
--- /dev/null
+++ b/briar-tests/.classpath
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<classpath>
+	<classpathentry kind="src" path="src"/>
+	<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/J2SE-1.5"/>
+	<classpathentry kind="lib" path="libs/hamcrest-core-1.1.jar"/>
+	<classpathentry kind="lib" path="libs/hamcrest-library-1.1.jar"/>
+	<classpathentry kind="lib" path="libs/jmock-2.5.1.jar"/>
+	<classpathentry kind="lib" path="libs/junit-4.9b3.jar"/>
+	<classpathentry combineaccessrules="false" kind="src" path="/briar-core"/>
+	<classpathentry kind="lib" path="/briar-core/android.jar"/>
+	<classpathentry kind="output" path="bin"/>
+</classpath>
diff --git a/briar-tests/.gitignore b/briar-tests/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..ba077a4031add5b3a04384f8b9cfc414efbf47dd
--- /dev/null
+++ b/briar-tests/.gitignore
@@ -0,0 +1 @@
+bin
diff --git a/briar-tests/.project b/briar-tests/.project
new file mode 100644
index 0000000000000000000000000000000000000000..fd15c6bba7eaa47b3b2c282d8ef8cf6fd534eecc
--- /dev/null
+++ b/briar-tests/.project
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<projectDescription>
+	<name>briar-tests</name>
+	<comment></comment>
+	<projects>
+	</projects>
+	<buildSpec>
+		<buildCommand>
+			<name>org.eclipse.jdt.core.javabuilder</name>
+			<arguments>
+			</arguments>
+		</buildCommand>
+	</buildSpec>
+	<natures>
+		<nature>org.eclipse.jdt.core.javanature</nature>
+	</natures>
+</projectDescription>
diff --git a/briar-tests/libs/hamcrest-core-1.1.jar b/briar-tests/libs/hamcrest-core-1.1.jar
new file mode 100644
index 0000000000000000000000000000000000000000..5f1d5ce0c3d60472692cda24885c92042a693ac0
Binary files /dev/null and b/briar-tests/libs/hamcrest-core-1.1.jar differ
diff --git a/briar-tests/libs/hamcrest-library-1.1.jar b/briar-tests/libs/hamcrest-library-1.1.jar
new file mode 100644
index 0000000000000000000000000000000000000000..40610c9b4a2343cc8891a055dcd301e8d142d937
Binary files /dev/null and b/briar-tests/libs/hamcrest-library-1.1.jar differ
diff --git a/briar-tests/libs/jmock-2.5.1.jar b/briar-tests/libs/jmock-2.5.1.jar
new file mode 100644
index 0000000000000000000000000000000000000000..4415dfbc94f8515bef4495976bcdb0fa5e6b5981
Binary files /dev/null and b/briar-tests/libs/jmock-2.5.1.jar differ
diff --git a/briar-tests/libs/junit-4.9b3.jar b/briar-tests/libs/junit-4.9b3.jar
new file mode 100644
index 0000000000000000000000000000000000000000..8c784e5810eed4a350abcd9d48afe20b8f20eb2c
Binary files /dev/null and b/briar-tests/libs/junit-4.9b3.jar differ
diff --git a/briar-tests/src/.gitignore b/briar-tests/src/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..94260a35069411445665db3f9b9b41ee347f32af
--- /dev/null
+++ b/briar-tests/src/.gitignore
@@ -0,0 +1,2 @@
+build
+test.tmp
diff --git a/briar-tests/src/build.xml b/briar-tests/src/build.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f0bea95468f7f8d930b0a92b3746f159832c9115
--- /dev/null
+++ b/briar-tests/src/build.xml
@@ -0,0 +1,118 @@
+<project name='test' default='test'>
+	<fileset id='core-jars' dir='../../briar-core/libs'>
+		<include name='*.jar'/>
+	</fileset>
+	<fileset id='test-jars' dir='../libs'>
+		<include name='*.jar'/>
+	</fileset>
+	<path id='android-jar'>
+		<pathelement location='../../briar-core/android.jar'/>
+	</path>
+	<path id='core-classes'>
+		<pathelement location='../../briar-core/build'/>
+	</path>
+	<path id='test-classes'>
+		<pathelement location='../build'/>
+	</path>
+	<target name='clean'>
+		<delete dir='../../briar-core/build'/>
+		<delete dir='../build'/>
+		<delete dir='test.tmp'/>
+	</target>
+	<target name='compile'>
+		<mkdir dir='../../briar-core/build'/>
+		<javac srcdir='../../briar-core/src'
+			destdir='../../briar-core/build' source='1.5'
+			includeantruntime='false' debug='off'>
+			<classpath>
+				<fileset refid='core-jars'/>
+				<path refid='android-jar'/>
+				<path refid='core-classes'/>
+			</classpath>
+		</javac>
+		<mkdir dir='../build'/>
+		<javac srcdir='.' destdir='../build' source='1.5'
+			includeantruntime='false' debug='off'>
+			<classpath>
+				<fileset refid='core-jars'/>
+				<fileset refid='test-jars'/>
+				<path refid='android-jar'/>
+				<path refid='core-classes'/>
+				<path refid='test-classes'/>
+			</classpath>
+		</javac>
+	</target>
+	<target name='test' depends='compile'>
+		<junit printsummary='on' fork='yes' forkmode='once'>
+			<assertions>
+				<enable/>
+			</assertions>
+			<classpath>
+				<fileset refid='core-jars'/>
+				<fileset refid='test-jars'/>
+				<path refid='core-classes'/>
+				<path refid='test-classes'/>
+			</classpath>
+			<jvmarg value='-Djava.library.path=../../briar-core/libs'/>
+			<test name='net.sf.briar.LockFairnessTest'/>
+			<test name='net.sf.briar.ProtocolIntegrationTest'/>
+			<test name='net.sf.briar.crypto.CounterModeTest'/>
+			<test name='net.sf.briar.crypto.ErasableKeyTest'/>
+			<test name='net.sf.briar.crypto.KeyAgreementTest'/>
+			<test name='net.sf.briar.crypto.KeyDerivationTest'/>
+			<test name='net.sf.briar.db.BasicH2Test'/>
+			<test name='net.sf.briar.db.DatabaseCleanerImplTest'/>
+			<test name='net.sf.briar.db.DatabaseComponentImplTest'/>
+			<test name='net.sf.briar.lifecycle.ShutdownManagerImplTest'/>
+			<test name='net.sf.briar.lifecycle.WindowsShutdownManagerImplTest'/>
+			<test name='net.sf.briar.plugins.PluginManagerImplTest'/>
+			<test name='net.sf.briar.plugins.file.LinuxRemovableDriveFinderTest'/>
+			<test name='net.sf.briar.plugins.file.MacRemovableDriveFinderTest'/>
+			<test name='net.sf.briar.plugins.file.PollingRemovableDriveMonitorTest'/>
+			<test name='net.sf.briar.plugins.file.RemovableDrivePluginTest'/>
+			<test name='net.sf.briar.plugins.file.UnixRemovableDriveMonitorTest'/>
+			<test name='net.sf.briar.plugins.tcp.LanTcpPluginTest'/>
+			<test name='net.sf.briar.protocol.AckReaderTest'/>
+			<test name='net.sf.briar.protocol.BatchReaderTest'/>
+			<test name='net.sf.briar.protocol.ConstantsTest'/>
+			<test name='net.sf.briar.protocol.ConsumersTest'/>
+			<test name='net.sf.briar.protocol.OfferReaderTest'/>
+			<test name='net.sf.briar.protocol.ProtocolIntegrationTest'/>
+			<test name='net.sf.briar.protocol.ProtocolWriterImplTest'/>
+			<test name='net.sf.briar.protocol.RequestReaderTest'/>
+			<test name='net.sf.briar.protocol.UnverifiedBatchImplTest'/>
+			<test name='net.sf.briar.protocol.simplex.OutgoingSimplexConnectionTest'/>
+			<test name='net.sf.briar.protocol.simplex.SimplexProtocolIntegrationTest'/>
+			<test name='net.sf.briar.serial.ReaderImplTest'/>
+			<test name='net.sf.briar.serial.WriterImplTest'/>
+			<test name='net.sf.briar.transport.ConnectionReaderImplTest'/>
+			<test name='net.sf.briar.transport.ConnectionRegistryImplTest'/>
+			<test name='net.sf.briar.transport.ConnectionWindowTest'/>
+			<test name='net.sf.briar.transport.ConnectionWriterImplTest'/>
+			<test name='net.sf.briar.transport.IncomingEncryptionLayerTest'/>
+			<test name='net.sf.briar.transport.OutgoingEncryptionLayerTest'/>
+			<test name='net.sf.briar.transport.TransportIntegrationTest'/>
+			<test name='net.sf.briar.transport.TransportConnectionRecogniserTest'/>
+			<test name='net.sf.briar.util.ByteUtilsTest'/>
+			<test name='net.sf.briar.util.FileUtilsTest'/>
+			<test name='net.sf.briar.util.StringUtilsTest'/>
+			<test name='net.sf.briar.util.ZipUtilsTest'/>
+		</junit>
+	</target>
+	<target name='test-slow' depends='compile'>
+		<junit printsummary='withOutAndErr' fork='yes' forkmode='once'>
+			<assertions>
+				<enable/>
+			</assertions>
+			<classpath>
+				<fileset refid='core-jars'/>
+				<fileset refid='test-jars'/>
+				<path refid='core-classes'/>
+				<path refid='test-classes'/>
+			</classpath>
+			<jvmarg value='-Djava.library.path=../../briar-core/libs'/>
+			<test name='net.sf.briar.db.H2DatabaseTest'/>
+			<test name='net.sf.briar.plugins.tor.TorPluginTest'/>
+		</junit>
+	</target>
+</project>
diff --git a/briar-tests/src/net/sf/briar/BriarTestCase.java b/briar-tests/src/net/sf/briar/BriarTestCase.java
new file mode 100644
index 0000000000000000000000000000000000000000..32f496aef4ab891bc4d8de2f19b1deb320b36d8f
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/BriarTestCase.java
@@ -0,0 +1,20 @@
+package net.sf.briar;
+
+
+import java.lang.Thread.UncaughtExceptionHandler;
+
+import junit.framework.TestCase;
+
+public abstract class BriarTestCase extends TestCase {
+
+	public BriarTestCase() {
+		super();
+		// Ensure exceptions thrown on worker threads cause tests to fail
+		UncaughtExceptionHandler fail = new UncaughtExceptionHandler() {
+			public void uncaughtException(Thread thread, Throwable throwable) {
+				fail();
+			}
+		};
+		Thread.setDefaultUncaughtExceptionHandler(fail);
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/LockFairnessTest.java b/briar-tests/src/net/sf/briar/LockFairnessTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..5560855a0d6cbb2847472fcb66e29e392f8d93e3
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/LockFairnessTest.java
@@ -0,0 +1,161 @@
+package net.sf.briar;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+import org.junit.Test;
+
+public class LockFairnessTest extends BriarTestCase {
+
+	@Test
+	public void testReadersCanShareTheLock() throws Exception {
+		// Use a fair lock
+		final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true);
+		final CountDownLatch firstReaderHasLock = new CountDownLatch(1);
+		final CountDownLatch firstReaderHasFinished = new CountDownLatch(1);
+		final CountDownLatch secondReaderHasLock = new CountDownLatch(1);
+		final CountDownLatch secondReaderHasFinished = new CountDownLatch(1);
+		// First reader
+		Thread first = new Thread() {
+			@Override
+			public void run() {
+				try {
+					// Acquire the lock
+					lock.readLock().lock();
+					try {
+						// Allow the second reader to acquire the lock
+						firstReaderHasLock.countDown();
+						// Wait for the second reader to acquire the lock
+						assertTrue(secondReaderHasLock.await(10, SECONDS));
+					} finally {
+						// Release the lock
+						lock.readLock().unlock();
+					}
+				} catch(InterruptedException e) {
+					fail();
+				}
+				firstReaderHasFinished.countDown();
+			}
+		};
+		first.start();
+		// Second reader
+		Thread second = new Thread() {
+			@Override
+			public void run() {
+				try {
+					// Wait for the first reader to acquire the lock
+					assertTrue(firstReaderHasLock.await(10, SECONDS));
+					// Acquire the lock
+					lock.readLock().lock();
+					try {
+						// Allow the first reader to release the lock
+						secondReaderHasLock.countDown();
+					} finally {
+						// Release the lock
+						lock.readLock().unlock();
+					}
+				} catch(InterruptedException e) {
+					fail();
+				}
+				secondReaderHasFinished.countDown();
+			}
+		};
+		second.start();
+		// Wait for both readers to finish
+		assertTrue(firstReaderHasFinished.await(10, SECONDS));
+		assertTrue(secondReaderHasFinished.await(10, SECONDS));
+	}
+
+	@Test
+	public void testWritersDoNotStarve() throws Exception {
+		// Use a fair lock
+		final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true);
+		final CountDownLatch firstReaderHasLock = new CountDownLatch(1);
+		final CountDownLatch firstReaderHasFinished = new CountDownLatch(1);
+		final CountDownLatch secondReaderHasFinished = new CountDownLatch(1);
+		final CountDownLatch writerHasFinished = new CountDownLatch(1);
+		final AtomicBoolean secondReaderHasHeldLock = new AtomicBoolean(false);
+		final AtomicBoolean writerHasHeldLock = new AtomicBoolean(false);
+		// First reader
+		Thread first = new Thread() {
+			@Override
+			public void run() {
+				try {
+					// Acquire the lock
+					lock.readLock().lock();
+					try {
+						// Allow the other threads to acquire the lock
+						firstReaderHasLock.countDown();
+						// Wait for both other threads to wait for the lock
+						while(lock.getQueueLength() < 2) Thread.sleep(10);
+						// No other thread should have acquired the lock
+						assertFalse(secondReaderHasHeldLock.get());
+						assertFalse(writerHasHeldLock.get());
+					} finally {
+						// Release the lock
+						lock.readLock().unlock();
+					}
+				} catch(InterruptedException e) {
+					fail();
+				}
+				firstReaderHasFinished.countDown();
+			}
+		};
+		first.start();
+		// Writer
+		Thread writer = new Thread() {
+			@Override
+			public void run() {
+				try {
+					// Wait for the first reader to acquire the lock
+					assertTrue(firstReaderHasLock.await(10, SECONDS));
+					// Acquire the lock
+					lock.writeLock().lock();
+					try {
+						writerHasHeldLock.set(true);
+						// The second reader should not overtake the writer
+						assertFalse(secondReaderHasHeldLock.get());
+					} finally {
+						lock.writeLock().unlock();
+					}
+				} catch(InterruptedException e) {
+					fail();
+				}
+				writerHasFinished.countDown();
+			}
+		};
+		writer.start();
+		// Second reader
+		Thread second = new Thread() {
+			@Override
+			public void run() {
+				try {
+					// Wait for the first reader to acquire the lock
+					assertTrue(firstReaderHasLock.await(10, SECONDS));
+					// Wait for the writer to wait for the lock
+					while(lock.getQueueLength() < 1) Thread.sleep(10);
+					// Acquire the lock
+					lock.readLock().lock();
+					try {
+						secondReaderHasHeldLock.set(true);
+						// The second reader should not overtake the writer
+						assertTrue(writerHasHeldLock.get());
+					} finally {
+						lock.readLock().unlock();
+					}
+				} catch(InterruptedException e) {
+					fail();
+				}
+				secondReaderHasFinished.countDown();
+			}
+		};
+		second.start();
+		// Wait for all the threads to finish
+		assertTrue(firstReaderHasFinished.await(10, SECONDS));
+		assertTrue(secondReaderHasFinished.await(10, SECONDS));
+		assertTrue(writerHasFinished.await(10, SECONDS));
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/ProtocolIntegrationTest.java b/briar-tests/src/net/sf/briar/ProtocolIntegrationTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..bbd7af2f9a7edb4387cac173311376d32b2fd1af
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/ProtocolIntegrationTest.java
@@ -0,0 +1,264 @@
+package net.sf.briar;
+
+import static net.sf.briar.api.transport.TransportConstants.TAG_LENGTH;
+import static org.junit.Assert.assertArrayEquals;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.security.KeyPair;
+import java.util.Arrays;
+import java.util.BitSet;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Random;
+
+import net.sf.briar.api.ContactId;
+import net.sf.briar.api.crypto.CryptoComponent;
+import net.sf.briar.api.protocol.Ack;
+import net.sf.briar.api.protocol.Author;
+import net.sf.briar.api.protocol.AuthorFactory;
+import net.sf.briar.api.protocol.Batch;
+import net.sf.briar.api.protocol.BatchId;
+import net.sf.briar.api.protocol.Group;
+import net.sf.briar.api.protocol.GroupFactory;
+import net.sf.briar.api.protocol.GroupId;
+import net.sf.briar.api.protocol.Message;
+import net.sf.briar.api.protocol.MessageFactory;
+import net.sf.briar.api.protocol.MessageId;
+import net.sf.briar.api.protocol.Offer;
+import net.sf.briar.api.protocol.PacketFactory;
+import net.sf.briar.api.protocol.ProtocolReader;
+import net.sf.briar.api.protocol.ProtocolReaderFactory;
+import net.sf.briar.api.protocol.ProtocolWriter;
+import net.sf.briar.api.protocol.ProtocolWriterFactory;
+import net.sf.briar.api.protocol.RawBatch;
+import net.sf.briar.api.protocol.Request;
+import net.sf.briar.api.protocol.SubscriptionUpdate;
+import net.sf.briar.api.protocol.Transport;
+import net.sf.briar.api.protocol.TransportId;
+import net.sf.briar.api.protocol.TransportUpdate;
+import net.sf.briar.api.transport.ConnectionContext;
+import net.sf.briar.api.transport.ConnectionReader;
+import net.sf.briar.api.transport.ConnectionReaderFactory;
+import net.sf.briar.api.transport.ConnectionWriter;
+import net.sf.briar.api.transport.ConnectionWriterFactory;
+import net.sf.briar.clock.ClockModule;
+import net.sf.briar.crypto.CryptoModule;
+import net.sf.briar.db.DatabaseModule;
+import net.sf.briar.lifecycle.LifecycleModule;
+import net.sf.briar.protocol.ProtocolModule;
+import net.sf.briar.protocol.duplex.DuplexProtocolModule;
+import net.sf.briar.protocol.simplex.SimplexProtocolModule;
+import net.sf.briar.serial.SerialModule;
+import net.sf.briar.transport.TransportModule;
+
+import org.junit.Test;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+
+public class ProtocolIntegrationTest extends BriarTestCase {
+
+	private final BatchId ack = new BatchId(TestUtils.getRandomId());
+	private final long timestamp = System.currentTimeMillis();
+
+	private final ConnectionReaderFactory connectionReaderFactory;
+	private final ConnectionWriterFactory connectionWriterFactory;
+	private final ProtocolReaderFactory protocolReaderFactory;
+	private final ProtocolWriterFactory protocolWriterFactory;
+	private final PacketFactory packetFactory;
+	private final CryptoComponent crypto;
+	private final ContactId contactId;
+	private final TransportId transportId;
+	private final byte[] secret;
+	private final Author author;
+	private final Group group, group1;
+	private final Message message, message1, message2, message3;
+	private final String authorName = "Alice";
+	private final String subject = "Hello";
+	private final String messageBody = "Hello world";
+	private final Collection<Transport> transports;
+
+	public ProtocolIntegrationTest() throws Exception {
+		super();
+		Injector i = Guice.createInjector(new ClockModule(), new CryptoModule(),
+				new DatabaseModule(), new LifecycleModule(),
+				new ProtocolModule(), new SerialModule(),
+				new TestDatabaseModule(), new SimplexProtocolModule(),
+				new TransportModule(), new DuplexProtocolModule());
+		connectionReaderFactory = i.getInstance(ConnectionReaderFactory.class);
+		connectionWriterFactory = i.getInstance(ConnectionWriterFactory.class);
+		protocolReaderFactory = i.getInstance(ProtocolReaderFactory.class);
+		protocolWriterFactory = i.getInstance(ProtocolWriterFactory.class);
+		packetFactory = i.getInstance(PacketFactory.class);
+		crypto = i.getInstance(CryptoComponent.class);
+		contactId = new ContactId(234);
+		transportId = new TransportId(TestUtils.getRandomId());
+		// Create a shared secret
+		Random r = new Random();
+		secret = new byte[32];
+		r.nextBytes(secret);
+		// Create two groups: one restricted, one unrestricted
+		GroupFactory groupFactory = i.getInstance(GroupFactory.class);
+		group = groupFactory.createGroup("Unrestricted group", null);
+		KeyPair groupKeyPair = crypto.generateSignatureKeyPair();
+		group1 = groupFactory.createGroup("Restricted group",
+				groupKeyPair.getPublic().getEncoded());
+		// Create an author
+		AuthorFactory authorFactory = i.getInstance(AuthorFactory.class);
+		KeyPair authorKeyPair = crypto.generateSignatureKeyPair();
+		author = authorFactory.createAuthor(authorName,
+				authorKeyPair.getPublic().getEncoded());
+		// Create two messages to each group: one anonymous, one pseudonymous
+		MessageFactory messageFactory = i.getInstance(MessageFactory.class);
+		message = messageFactory.createMessage(null, group, subject,
+				messageBody.getBytes("UTF-8"));
+		message1 = messageFactory.createMessage(null, group1,
+				groupKeyPair.getPrivate(), subject,
+				messageBody.getBytes("UTF-8"));
+		message2 = messageFactory.createMessage(null, group, author,
+				authorKeyPair.getPrivate(), subject,
+				messageBody.getBytes("UTF-8"));
+		message3 = messageFactory.createMessage(null, group1,
+				groupKeyPair.getPrivate(), author, authorKeyPair.getPrivate(),
+				subject, messageBody.getBytes("UTF-8"));
+		// Create some transports
+		TransportId transportId = new TransportId(TestUtils.getRandomId());
+		Transport transport = new Transport(transportId,
+				Collections.singletonMap("bar", "baz"));
+		transports = Collections.singletonList(transport);
+	}
+
+	@Test
+	public void testWriteAndRead() throws Exception {
+		read(write());
+	}
+
+	private byte[] write() throws Exception {
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		ConnectionContext ctx = new ConnectionContext(contactId, transportId,
+				secret.clone(), 0L, true);
+		ConnectionWriter conn = connectionWriterFactory.createConnectionWriter(
+				out, Long.MAX_VALUE, ctx, false, true);
+		OutputStream out1 = conn.getOutputStream();
+		ProtocolWriter writer = protocolWriterFactory.createProtocolWriter(out1,
+				false);
+
+		Ack a = packetFactory.createAck(Collections.singletonList(ack));
+		writer.writeAck(a);
+
+		Collection<byte[]> batch = Arrays.asList(message.getSerialised(),
+				message1.getSerialised(), message2.getSerialised(),
+				message3.getSerialised());
+		RawBatch b = packetFactory.createBatch(batch);
+		writer.writeBatch(b);
+
+		Collection<MessageId> offer = Arrays.asList(message.getId(),
+				message1.getId(), message2.getId(), message3.getId());
+		Offer o = packetFactory.createOffer(offer);
+		writer.writeOffer(o);
+
+		BitSet requested = new BitSet(4);
+		requested.set(1);
+		requested.set(3);
+		Request r = packetFactory.createRequest(requested, 4);
+		writer.writeRequest(r);
+
+		// Use a LinkedHashMap for predictable iteration order
+		Map<Group, Long> subs = new LinkedHashMap<Group, Long>();
+		subs.put(group, 0L);
+		subs.put(group1, 0L);
+		SubscriptionUpdate s = packetFactory.createSubscriptionUpdate(
+				Collections.<GroupId, GroupId>emptyMap(), subs, 0L, timestamp);
+		writer.writeSubscriptionUpdate(s);
+
+		TransportUpdate t = packetFactory.createTransportUpdate(transports,
+				timestamp);
+		writer.writeTransportUpdate(t);
+
+		writer.flush();
+		return out.toByteArray();
+	}
+
+	private void read(byte[] connectionData) throws Exception {
+		InputStream in = new ByteArrayInputStream(connectionData);
+		byte[] tag = new byte[TAG_LENGTH];
+		assertEquals(TAG_LENGTH, in.read(tag, 0, TAG_LENGTH));
+		// FIXME: Check that the expected tag was received
+		ConnectionContext ctx = new ConnectionContext(contactId, transportId,
+				secret.clone(), 0L, false);
+		ConnectionReader conn = connectionReaderFactory.createConnectionReader(
+				in, ctx, true, true);
+		InputStream in1 = conn.getInputStream();
+		ProtocolReader reader = protocolReaderFactory.createProtocolReader(in1);
+
+		// Read the ack
+		assertTrue(reader.hasAck());
+		Ack a = reader.readAck();
+		assertEquals(Collections.singletonList(ack), a.getBatchIds());
+
+		// Read and verify the batch
+		assertTrue(reader.hasBatch());
+		Batch b = reader.readBatch().verify();
+		Collection<Message> messages = b.getMessages();
+		assertEquals(4, messages.size());
+		Iterator<Message> it = messages.iterator();
+		checkMessageEquality(message, it.next());
+		checkMessageEquality(message1, it.next());
+		checkMessageEquality(message2, it.next());
+		checkMessageEquality(message3, it.next());
+
+		// Read the offer
+		assertTrue(reader.hasOffer());
+		Offer o = reader.readOffer();
+		Collection<MessageId> offered = o.getMessageIds();
+		assertEquals(4, offered.size());
+		Iterator<MessageId> it1 = offered.iterator();
+		assertEquals(message.getId(), it1.next());
+		assertEquals(message1.getId(), it1.next());
+		assertEquals(message2.getId(), it1.next());
+		assertEquals(message3.getId(), it1.next());
+
+		// Read the request
+		assertTrue(reader.hasRequest());
+		Request req = reader.readRequest();
+		BitSet requested = req.getBitmap();
+		assertFalse(requested.get(0));
+		assertTrue(requested.get(1));
+		assertFalse(requested.get(2));
+		assertTrue(requested.get(3));
+		// If there are any padding bits, they should all be zero
+		assertEquals(2, requested.cardinality());
+
+		// Read the subscription update
+		assertTrue(reader.hasSubscriptionUpdate());
+		SubscriptionUpdate s = reader.readSubscriptionUpdate();
+		Map<Group, Long> subs = s.getSubscriptions();
+		assertEquals(2, subs.size());
+		assertEquals(Long.valueOf(0L), subs.get(group));
+		assertEquals(Long.valueOf(0L), subs.get(group1));
+		assertTrue(s.getTimestamp() == timestamp);
+
+		// Read the transport update
+		assertTrue(reader.hasTransportUpdate());
+		TransportUpdate t = reader.readTransportUpdate();
+		assertEquals(transports, t.getTransports());
+		assertTrue(t.getTimestamp() == timestamp);
+
+		in.close();
+	}
+
+	private void checkMessageEquality(Message m1, Message m2) {
+		assertEquals(m1.getId(), m2.getId());
+		assertEquals(m1.getParent(), m2.getParent());
+		assertEquals(m1.getGroup(), m2.getGroup());
+		assertEquals(m1.getAuthor(), m2.getAuthor());
+		assertEquals(m1.getTimestamp(), m2.getTimestamp());
+		assertArrayEquals(m1.getSerialised(), m2.getSerialised());
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/TestDatabaseConfig.java b/briar-tests/src/net/sf/briar/TestDatabaseConfig.java
new file mode 100644
index 0000000000000000000000000000000000000000..fdfedb413f245fa2bd9095d184a7ec0a125c9129
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/TestDatabaseConfig.java
@@ -0,0 +1,33 @@
+package net.sf.briar;
+
+import java.io.File;
+
+import net.sf.briar.api.crypto.Password;
+import net.sf.briar.api.db.DatabaseConfig;
+
+public class TestDatabaseConfig implements DatabaseConfig {
+
+	private final File dir;
+	private final long maxSize;
+
+	public TestDatabaseConfig(File dir, long maxSize) {
+		this.dir = dir;
+		this.maxSize = maxSize;
+	}
+
+	public File getDataDirectory() {
+		return dir;
+	}
+
+	public Password getPassword() {
+		return new Password() {
+			public char[] getPassword() {
+				return "foo bar".toCharArray();
+			}
+		};
+	}
+
+	public long getMaxSize() {
+		return maxSize;
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/TestDatabaseModule.java b/briar-tests/src/net/sf/briar/TestDatabaseModule.java
new file mode 100644
index 0000000000000000000000000000000000000000..5479d9c6bd62248c3f605e59776b770a3d68bf1d
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/TestDatabaseModule.java
@@ -0,0 +1,29 @@
+package net.sf.briar;
+
+import java.io.File;
+
+import net.sf.briar.api.db.DatabaseConfig;
+
+import com.google.inject.AbstractModule;
+
+public class TestDatabaseModule extends AbstractModule {
+
+	private final DatabaseConfig config;
+
+	public TestDatabaseModule() {
+		this(new File("."), Long.MAX_VALUE);
+	}
+
+	public TestDatabaseModule(File dir) {
+		this(dir, Long.MAX_VALUE);
+	}
+
+	public TestDatabaseModule(File dir, long maxSize) {
+		this.config = new TestDatabaseConfig(dir, maxSize);
+	}
+
+	@Override
+	protected void configure() {
+		bind(DatabaseConfig.class).toInstance(config);
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/TestUtils.java b/briar-tests/src/net/sf/briar/TestUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..e5c4f6cfe231e52535f32b75d12a551a250911ff
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/TestUtils.java
@@ -0,0 +1,76 @@
+package net.sf.briar;
+
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.PrintStream;
+import java.util.Random;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import junit.framework.TestCase;
+import net.sf.briar.api.protocol.UniqueId;
+
+public class TestUtils {
+
+	private static final AtomicInteger nextTestDir =
+		new AtomicInteger((int) (Math.random() * 1000 * 1000));
+	private static final Random random = new Random();
+
+	public static void delete(File f) {
+		if(f.isDirectory()) for(File child : f.listFiles()) delete(child);
+		f.delete();
+	}
+
+	public static void createFile(File f, String s) throws IOException {
+		f.getParentFile().mkdirs();
+		PrintStream out = new PrintStream(new FileOutputStream(f));
+		out.print(s);
+		out.flush();
+		out.close();
+	}
+
+	public static File getTestDirectory() {
+		int name = nextTestDir.getAndIncrement();
+		File testDir = new File("test.tmp/" + name);
+		return testDir;
+	}
+
+	public static void deleteTestDirectory(File testDir) {
+		delete(testDir);
+		testDir.getParentFile().delete(); // Delete if empty
+	}
+
+	public static File getBuildDirectory() {
+		File build = new File("build"); // Ant
+		if(build.exists() && build.isDirectory()) return build;
+		File bin = new File("bin"); // Eclipse
+		if(bin.exists() && bin.isDirectory()) return bin;
+		throw new RuntimeException("Could not find build directory");
+	}
+
+	public static File getFontDirectory() {
+		File f = new File("i18n");
+		if(f.exists() && f.isDirectory()) return f;
+		f = new File("../i18n");
+		if(f.exists() && f.isDirectory()) return f;
+		throw new RuntimeException("Could not find font directory");
+	}
+
+	public static byte[] getRandomId() {
+		byte[] b = new byte[UniqueId.LENGTH];
+		random.nextBytes(b);
+		return b;
+	}
+
+	public static void readFully(InputStream in, byte[] b) throws IOException {
+		int offset = 0;
+		while(offset < b.length) {
+			int read = in.read(b, offset, b.length - offset);
+			if(read == -1) break;
+			offset += read;
+		}
+		TestCase.assertEquals(b.length, offset);
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/crypto/CounterModeTest.java b/briar-tests/src/net/sf/briar/crypto/CounterModeTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..96cde200146d6cbfd3ee34df429afa8b525e65b3
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/crypto/CounterModeTest.java
@@ -0,0 +1,156 @@
+package net.sf.briar.crypto;
+
+import java.security.GeneralSecurityException;
+import java.security.SecureRandom;
+import java.security.Security;
+import java.util.HashSet;
+import java.util.Set;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.api.Bytes;
+
+import org.junit.Test;
+import org.spongycastle.jce.provider.BouncyCastleProvider;
+
+public class CounterModeTest extends BriarTestCase {
+
+	private static final String CIPHER_ALGO = "AES";
+	private static final String CIPHER_MODE = "AES/CTR/NoPadding";
+	private static final String PROVIDER = "SC";
+	private static final int KEY_SIZE_BYTES = 32; // AES-256
+	private static final int BLOCK_SIZE_BYTES = 16;
+
+	private final SecureRandom random;
+	private final byte[] keyBytes;
+	private final SecretKeySpec key;
+
+	public CounterModeTest() {
+		super();
+		Security.addProvider(new BouncyCastleProvider());
+		random = new SecureRandom();
+		keyBytes = new byte[KEY_SIZE_BYTES];
+		random.nextBytes(keyBytes);
+		key = new SecretKeySpec(keyBytes, CIPHER_ALGO);
+	}
+
+	@Test
+	public void testEveryBitOfIvIsSignificant()
+	throws GeneralSecurityException {
+		// Set each bit of the IV in turn, encrypt the same plaintext and check
+		// that all the resulting ciphertexts are distinct
+		byte[] plaintext = new byte[BLOCK_SIZE_BYTES];
+		random.nextBytes(plaintext);
+		Set<Bytes> ciphertexts = new HashSet<Bytes>();
+		for(int i = 0; i < BLOCK_SIZE_BYTES * 8; i++) {
+			// Set the i^th bit of the IV
+			byte[] ivBytes = new byte[BLOCK_SIZE_BYTES];
+			ivBytes[i / 8] |= (byte) (128 >> i % 8);
+			IvParameterSpec iv = new IvParameterSpec(ivBytes);
+			// Encrypt the plaintext
+			Cipher cipher = Cipher.getInstance(CIPHER_MODE, PROVIDER);
+			cipher.init(Cipher.ENCRYPT_MODE, key, iv);
+			byte[] ciphertext =
+				new byte[cipher.getOutputSize(plaintext.length)];
+			cipher.doFinal(plaintext, 0, plaintext.length, ciphertext);
+			ciphertexts.add(new Bytes(ciphertext));
+		}
+		// All the ciphertexts should be distinct using Arrays.equals()
+		assertEquals(BLOCK_SIZE_BYTES * 8, ciphertexts.size());
+	}
+
+	@Test
+	public void testRepeatedIvsProduceRepeatedCiphertexts()
+	throws GeneralSecurityException {
+		// This is the inverse of the previous test, to check that the
+		// distinct ciphertexts were due to using distinct IVs
+		byte[] plaintext = new byte[BLOCK_SIZE_BYTES];
+		random.nextBytes(plaintext);
+		byte[] ivBytes = new byte[BLOCK_SIZE_BYTES];
+		random.nextBytes(ivBytes);
+		IvParameterSpec iv = new IvParameterSpec(ivBytes);
+		Set<Bytes> ciphertexts = new HashSet<Bytes>();
+		for(int i = 0; i < BLOCK_SIZE_BYTES * 8; i++) {
+			Cipher cipher = Cipher.getInstance(CIPHER_MODE, PROVIDER);
+			cipher.init(Cipher.ENCRYPT_MODE, key, iv);
+			byte[] ciphertext =
+				new byte[cipher.getOutputSize(plaintext.length)];
+			cipher.doFinal(plaintext, 0, plaintext.length, ciphertext);
+			ciphertexts.add(new Bytes(ciphertext));
+		}
+		assertEquals(1, ciphertexts.size());
+	}
+
+	@Test
+	public void testLeastSignificantBitsUsedAsCounter()
+	throws GeneralSecurityException {
+		// Initialise the least significant 16 bits of the IV to zero and
+		// encrypt ten blocks of zeroes
+		byte[] plaintext = new byte[BLOCK_SIZE_BYTES * 10];
+		byte[] ivBytes = new byte[BLOCK_SIZE_BYTES];
+		random.nextBytes(ivBytes);
+		ivBytes[BLOCK_SIZE_BYTES - 2] = 0;
+		ivBytes[BLOCK_SIZE_BYTES - 1] = 0;
+		IvParameterSpec iv = new IvParameterSpec(ivBytes);
+		Cipher cipher = Cipher.getInstance(CIPHER_MODE, PROVIDER);
+		cipher.init(Cipher.ENCRYPT_MODE, key, iv);
+		byte[] ciphertext = new byte[cipher.getOutputSize(plaintext.length)];
+		cipher.doFinal(plaintext, 0, plaintext.length, ciphertext);
+		// Make sure the IV array hasn't been modified
+		assertEquals(0, ivBytes[BLOCK_SIZE_BYTES - 2]);
+		assertEquals(0, ivBytes[BLOCK_SIZE_BYTES - 1]);
+		// Initialise the least significant 16 bits of the IV to one and
+		// encrypt another ten blocks of zeroes
+		ivBytes[BLOCK_SIZE_BYTES - 1] = 1;
+		iv = new IvParameterSpec(ivBytes);
+		cipher = Cipher.getInstance(CIPHER_MODE, PROVIDER);
+		cipher.init(Cipher.ENCRYPT_MODE, key, iv);
+		byte[] ciphertext1 = new byte[cipher.getOutputSize(plaintext.length)];
+		cipher.doFinal(plaintext, 0, plaintext.length, ciphertext1);
+		// The last nine blocks of the first ciphertext should be identical to
+		// the first nine blocks of the second ciphertext
+		for(int i = 0; i < BLOCK_SIZE_BYTES * 9; i++) {
+			assertEquals(ciphertext[i + BLOCK_SIZE_BYTES], ciphertext1[i]);
+		}
+	}
+
+	@Test
+	public void testCounterUsesMoreThan16Bits()
+	throws GeneralSecurityException {
+		// Initialise the least significant bits of the IV to 2^16-1 and
+		// encrypt ten blocks of zeroes
+		byte[] plaintext = new byte[BLOCK_SIZE_BYTES * 10];
+		byte[] ivBytes = new byte[BLOCK_SIZE_BYTES];
+		random.nextBytes(ivBytes);
+		ivBytes[BLOCK_SIZE_BYTES - 3] = 0;
+		ivBytes[BLOCK_SIZE_BYTES - 2] = (byte) 255;
+		ivBytes[BLOCK_SIZE_BYTES - 1] = (byte) 255;
+		IvParameterSpec iv = new IvParameterSpec(ivBytes);
+		Cipher cipher = Cipher.getInstance(CIPHER_MODE, PROVIDER);
+		cipher.init(Cipher.ENCRYPT_MODE, key, iv);
+		byte[] ciphertext = new byte[cipher.getOutputSize(plaintext.length)];
+		cipher.doFinal(plaintext, 0, plaintext.length, ciphertext);
+		// Make sure the IV array hasn't been modified
+		assertEquals(0, ivBytes[BLOCK_SIZE_BYTES - 3]);
+		assertEquals((byte) 255, ivBytes[BLOCK_SIZE_BYTES - 2]);
+		assertEquals((byte) 255, ivBytes[BLOCK_SIZE_BYTES - 1]);
+		// Initialise the least significant bits of the IV to 2^16 and
+		// encrypt another ten blocks of zeroes
+		ivBytes[BLOCK_SIZE_BYTES - 3] = 1;
+		ivBytes[BLOCK_SIZE_BYTES - 2] = 0;
+		ivBytes[BLOCK_SIZE_BYTES - 1] = 0;
+		iv = new IvParameterSpec(ivBytes);
+		cipher = Cipher.getInstance(CIPHER_MODE, PROVIDER);
+		cipher.init(Cipher.ENCRYPT_MODE, key, iv);
+		byte[] ciphertext1 = new byte[cipher.getOutputSize(plaintext.length)];
+		cipher.doFinal(plaintext, 0, plaintext.length, ciphertext1);
+		// The last nine blocks of the first ciphertext should be identical to
+		// the first nine blocks of the second ciphertext
+		for(int i = 0; i < BLOCK_SIZE_BYTES * 9; i++) {
+			assertEquals(ciphertext[i + BLOCK_SIZE_BYTES], ciphertext1[i]);
+		}
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/crypto/ErasableKeyTest.java b/briar-tests/src/net/sf/briar/crypto/ErasableKeyTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..eb448a5505c74ff09741eb247fab91c77fb489a8
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/crypto/ErasableKeyTest.java
@@ -0,0 +1,79 @@
+package net.sf.briar.crypto;
+
+import static org.junit.Assert.assertArrayEquals;
+
+import java.util.Random;
+
+import javax.crypto.Cipher;
+import javax.crypto.Mac;
+import javax.crypto.spec.IvParameterSpec;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.api.crypto.ErasableKey;
+
+import org.junit.Test;
+
+public class ErasableKeyTest extends BriarTestCase {
+
+	private static final String CIPHER = "AES";
+	private static final String CIPHER_MODE = "AES/CTR/NoPadding";
+	private static final int IV_BYTES = 16; // 128 bits
+	private static final int KEY_BYTES = 32; // 256 bits
+	private static final String MAC = "HMacSHA384";
+
+	private final Random random = new Random();
+
+	@Test
+	public void testCopiesAreErased() {
+		byte[] master = new byte[KEY_BYTES];
+		random.nextBytes(master);
+		ErasableKey k = new ErasableKeyImpl(master, CIPHER);
+		byte[] copy = k.getEncoded();
+		assertArrayEquals(master, copy);
+		k.erase();
+		byte[] blank = new byte[KEY_BYTES];
+		assertArrayEquals(blank, master);
+		assertArrayEquals(blank, copy);
+	}
+
+	@Test
+	public void testErasureDoesNotAffectCipher() throws Exception {
+		byte[] key = new byte[KEY_BYTES];
+		random.nextBytes(key);
+		ErasableKey k = new ErasableKeyImpl(key, CIPHER);
+		Cipher c = Cipher.getInstance(CIPHER_MODE);
+		IvParameterSpec iv = new IvParameterSpec(new byte[IV_BYTES]);
+		c.init(Cipher.ENCRYPT_MODE, k, iv);
+		// Encrypt a blank plaintext
+		byte[] plaintext = new byte[123];
+		byte[] ciphertext = c.doFinal(plaintext);
+		// Erase the key and encrypt again - erase() was called after doFinal()
+		k.erase();
+		byte[] ciphertext1 = c.doFinal(plaintext);
+		// Encrypt again - this time erase() was called before doFinal()
+		byte[] ciphertext2 = c.doFinal(plaintext);
+		// The ciphertexts should match
+		assertArrayEquals(ciphertext, ciphertext1);
+		assertArrayEquals(ciphertext, ciphertext2);
+	}
+
+	@Test
+	public void testErasureDoesNotAffectMac() throws Exception {
+		byte[] key = new byte[KEY_BYTES];
+		random.nextBytes(key);
+		ErasableKey k = new ErasableKeyImpl(key, CIPHER);
+		Mac m = Mac.getInstance(MAC);
+		m.init(k);
+		// Authenticate a blank plaintext
+		byte[] plaintext = new byte[123];
+		byte[] mac = m.doFinal(plaintext);
+		// Erase the key and authenticate again
+		k.erase();
+		byte[] mac1 = m.doFinal(plaintext);
+		// Authenticate again
+		byte[] mac2 = m.doFinal(plaintext);
+		// The MACs should match
+		assertArrayEquals(mac, mac1);
+		assertArrayEquals(mac, mac2);
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/crypto/KeyAgreementTest.java b/briar-tests/src/net/sf/briar/crypto/KeyAgreementTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..01a8939409f95afe8e3244eb452f54818a2ded0a
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/crypto/KeyAgreementTest.java
@@ -0,0 +1,25 @@
+package net.sf.briar.crypto;
+
+import static org.junit.Assert.assertArrayEquals;
+
+import java.security.KeyPair;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.api.crypto.CryptoComponent;
+
+import org.junit.Test;
+
+public class KeyAgreementTest extends BriarTestCase {
+
+	@Test
+	public void testKeyAgreement() throws Exception {
+		CryptoComponent crypto = new CryptoComponentImpl();
+		KeyPair a = crypto.generateAgreementKeyPair();
+		byte[] aPub = a.getPublic().getEncoded();
+		KeyPair b = crypto.generateAgreementKeyPair();
+		byte[] bPub = b.getPublic().getEncoded();
+		byte[] aSecret = crypto.deriveInitialSecret(aPub, b, true);
+		byte[] bSecret = crypto.deriveInitialSecret(bPub, a, false);
+		assertArrayEquals(aSecret, bSecret);
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/crypto/KeyDerivationTest.java b/briar-tests/src/net/sf/briar/crypto/KeyDerivationTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..b05f536a45430b94af0e81c42a79c68b2720c6a4
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/crypto/KeyDerivationTest.java
@@ -0,0 +1,76 @@
+package net.sf.briar.crypto;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Random;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.api.crypto.CryptoComponent;
+import net.sf.briar.api.crypto.ErasableKey;
+
+import org.junit.Test;
+
+public class KeyDerivationTest extends BriarTestCase {
+
+	private final CryptoComponent crypto;
+	private final byte[] secret;
+
+	public KeyDerivationTest() {
+		super();
+		crypto = new CryptoComponentImpl();
+		secret = new byte[32];
+		new Random().nextBytes(secret);
+	}
+
+	@Test
+	public void testKeysAreDistinct() {
+		List<ErasableKey> keys = new ArrayList<ErasableKey>();
+		keys.add(crypto.deriveFrameKey(secret, 0, false, false));
+		keys.add(crypto.deriveFrameKey(secret, 0, false, true));
+		keys.add(crypto.deriveFrameKey(secret, 0, true, false));
+		keys.add(crypto.deriveFrameKey(secret, 0, true, true));
+		keys.add(crypto.deriveTagKey(secret, true));
+		keys.add(crypto.deriveTagKey(secret, false));
+		for(int i = 0; i < 4; i++) {
+			byte[] keyI = keys.get(i).getEncoded();
+			for(int j = 0; j < 4; j++) {
+				byte[] keyJ = keys.get(j).getEncoded();
+				assertEquals(i == j, Arrays.equals(keyI, keyJ));
+			}
+		}
+	}
+
+	@Test
+	public void testSecretAffectsDerivation() {
+		Random r = new Random();
+		List<byte[]> secrets = new ArrayList<byte[]>();
+		for(int i = 0; i < 20; i++) {
+			byte[] b = new byte[32];
+			r.nextBytes(b);
+			secrets.add(crypto.deriveNextSecret(b, 0));
+		}
+		for(int i = 0; i < 20; i++) {
+			byte[] secretI = secrets.get(i);
+			for(int j = 0; j < 20; j++) {
+				byte[] secretJ = secrets.get(j);
+				assertEquals(i == j, Arrays.equals(secretI, secretJ));
+			}
+		}
+	}
+
+	@Test
+	public void testConnectionNumberAffectsDerivation() {
+		List<byte[]> secrets = new ArrayList<byte[]>();
+		for(int i = 0; i < 20; i++) {
+			secrets.add(crypto.deriveNextSecret(secret.clone(), i));
+		}
+		for(int i = 0; i < 20; i++) {
+			byte[] secretI = secrets.get(i);
+			for(int j = 0; j < 20; j++) {
+				byte[] secretJ = secrets.get(j);
+				assertEquals(i == j, Arrays.equals(secretI, secretJ));
+			}
+		}
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/db/BasicH2Test.java b/briar-tests/src/net/sf/briar/db/BasicH2Test.java
new file mode 100644
index 0000000000000000000000000000000000000000..76e2384f663dd8e2d51f3f6512fb5e1c1f1956b9
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/db/BasicH2Test.java
@@ -0,0 +1,192 @@
+package net.sf.briar.db;
+
+import java.io.File;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.sql.Types;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.TestUtils;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class BasicH2Test extends BriarTestCase {
+
+	private static final String CREATE_TABLE =
+			"CREATE TABLE foo"
+					+ " (uniqueId BINARY(32),"
+					+ " name VARCHAR NOT NULL)";
+
+	private final File testDir = TestUtils.getTestDirectory();
+	private final File db = new File(testDir, "db");
+	private final String url = "jdbc:h2:" + db.getPath();
+
+	private Connection connection = null;
+
+	@Before
+	public void setUp() throws Exception {
+		testDir.mkdirs();
+		Class.forName("org.h2.Driver");
+		connection = DriverManager.getConnection(url);
+	}
+
+	@Test
+	public void testCreateTableAndAddRow() throws Exception {
+		// Create the table
+		createTable(connection);
+		// Generate an ID
+		byte[] id = new byte[32];
+		new Random().nextBytes(id);
+		// Insert the ID and name into the table
+		addRow(id, "foo");
+	}
+
+	@Test
+	public void testCreateTableAddAndRetrieveRow() throws Exception {
+		// Create the table
+		createTable(connection);
+		// Generate an ID
+		byte[] id = new byte[32];
+		new Random().nextBytes(id);
+		// Insert the ID and name into the table
+		addRow(id, "foo");
+		// Check that the name can be retrieved using the ID
+		assertEquals("foo", getName(id));
+	}
+
+	@Test
+	public void testSortOrder() throws Exception {
+		byte[] first = new byte[] {
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, -128
+		};
+		byte[] second = new byte[] {
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0
+		};
+		byte[] third = new byte[] {
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 0,
+				0, 0, 0, 0, 0, 0, 0, 127
+		};
+		// Create the table
+		createTable(connection);
+		// Insert the rows
+		addRow(first, "first");
+		addRow(second, "second");
+		addRow(third, "third");
+		addRow(null, "null");
+		// Check the ordering of the < operator: the null ID is not comparable
+		assertNull(getPredecessor(first));
+		assertEquals("first", getPredecessor(second));
+		assertEquals("second", getPredecessor(third));
+		assertNull(getPredecessor(null));
+		// Check the ordering of ORDER BY: nulls come first
+		List<String> names = getNames();
+		assertEquals(4, names.size());
+		assertEquals("null", names.get(0));
+		assertEquals("first", names.get(1));
+		assertEquals("second", names.get(2));
+		assertEquals("third", names.get(3));
+	}
+
+	private void createTable(Connection connection) throws SQLException {
+		try {
+			Statement s = connection.createStatement();
+			s.executeUpdate(CREATE_TABLE);
+			s.close();
+		} catch(SQLException e) {
+			connection.close();
+			throw e;
+		}
+	}
+
+	private void addRow(byte[] id, String name) throws SQLException {
+		String sql = "INSERT INTO foo (uniqueId, name) VALUES (?, ?)";
+		try {
+			PreparedStatement ps = connection.prepareStatement(sql);
+			if(id == null) ps.setNull(1, Types.BINARY);
+			else ps.setBytes(1, id);
+			ps.setString(2, name);
+			int rowsAffected = ps.executeUpdate();
+			ps.close();
+			assertEquals(1, rowsAffected);
+		} catch(SQLException e) {
+			connection.close();
+			throw e;
+		}
+	}
+
+	private String getName(byte[] id) throws SQLException {
+		String sql = "SELECT name FROM foo WHERE uniqueID = ?";
+		try {
+			PreparedStatement ps = connection.prepareStatement(sql);
+			if(id != null) ps.setBytes(1, id);
+			ResultSet rs = ps.executeQuery();
+			assertTrue(rs.next());
+			String name = rs.getString(1);
+			assertFalse(rs.next());
+			rs.close();
+			ps.close();
+			return name;
+		} catch(SQLException e) {
+			connection.close();
+			throw e;
+		}
+	}
+
+	private String getPredecessor(byte[] id) throws SQLException {
+		String sql = "SELECT name FROM foo WHERE uniqueId < ?"
+				+ " ORDER BY uniqueId DESC LIMIT ?";
+		try {
+			PreparedStatement ps = connection.prepareStatement(sql);
+			ps.setBytes(1, id);
+			ps.setInt(2, 1);
+			ResultSet rs = ps.executeQuery();
+			String name = rs.next() ? rs.getString(1) : null;
+			assertFalse(rs.next());
+			rs.close();
+			ps.close();
+			return name;
+		} catch(SQLException e) {
+			connection.close();
+			throw e;
+		}
+	}
+
+	private List<String> getNames() throws SQLException {
+		String sql = "SELECT name FROM foo ORDER BY uniqueId";
+		List<String> names = new ArrayList<String>();
+		try {
+			PreparedStatement ps = connection.prepareStatement(sql);
+			ResultSet rs = ps.executeQuery();
+			while(rs.next()) names.add(rs.getString(1));
+			rs.close();
+			ps.close();
+			return names;
+		} catch(SQLException e) {
+			connection.close();
+			throw e;
+		}
+	}
+
+	@After
+	public void tearDown() throws Exception {
+		if(connection != null) connection.close();
+		TestUtils.deleteTestDirectory(testDir);
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/db/DatabaseCleanerImplTest.java b/briar-tests/src/net/sf/briar/db/DatabaseCleanerImplTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..cbe77eda24e55292e8ef8825d69703d01002ac34
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/db/DatabaseCleanerImplTest.java
@@ -0,0 +1,67 @@
+package net.sf.briar.db;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import java.util.concurrent.CountDownLatch;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.api.clock.SystemTimer;
+import net.sf.briar.api.clock.Timer;
+import net.sf.briar.api.db.DbException;
+import net.sf.briar.db.DatabaseCleaner.Callback;
+
+import org.junit.Test;
+
+// FIXME: Use a mock timer
+public class DatabaseCleanerImplTest extends BriarTestCase {
+
+	@Test
+	public void testCleanerRunsPeriodically() throws Exception {
+		final CountDownLatch latch = new CountDownLatch(5);
+		Callback callback = new Callback() {
+
+			public void checkFreeSpaceAndClean() throws DbException {
+				latch.countDown();
+			}
+
+			public boolean shouldCheckFreeSpace() {
+				return true;
+			}
+		};
+		Timer timer = new SystemTimer();
+		DatabaseCleanerImpl cleaner = new DatabaseCleanerImpl(timer);
+		// Start the cleaner
+		cleaner.startCleaning(callback, 10L);
+		// The database should be cleaned five times (allow 5s for system load)
+		assertTrue(latch.await(5, SECONDS));
+		// Stop the cleaner
+		cleaner.stopCleaning();
+	}
+
+	@Test
+	public void testStoppingCleanerWakesItUp() throws Exception {
+		final CountDownLatch latch = new CountDownLatch(1);
+		Callback callback = new Callback() {
+
+			public void checkFreeSpaceAndClean() throws DbException {
+				latch.countDown();
+			}
+
+			public boolean shouldCheckFreeSpace() {
+				return true;
+			}
+		};
+		Timer timer = new SystemTimer();
+		DatabaseCleanerImpl cleaner = new DatabaseCleanerImpl(timer);
+		long start = System.currentTimeMillis();
+		// Start the cleaner
+		cleaner.startCleaning(callback, 10L * 1000L);
+		// The database should be cleaned once at startup
+		assertTrue(latch.await(5, SECONDS));
+		// Stop the cleaner (it should be waiting between sweeps)
+		cleaner.stopCleaning();
+		long end = System.currentTimeMillis();
+		// Check that much less than 10 seconds expired
+		assertTrue(end - start < 10L * 1000L);
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/db/DatabaseComponentImplTest.java b/briar-tests/src/net/sf/briar/db/DatabaseComponentImplTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..389bb46a6314464aae821f5d7a8a32bad54c6fd0
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/db/DatabaseComponentImplTest.java
@@ -0,0 +1,151 @@
+package net.sf.briar.db;
+
+import static net.sf.briar.db.DatabaseConstants.BYTES_PER_SWEEP;
+import static net.sf.briar.db.DatabaseConstants.MIN_FREE_SPACE;
+
+import java.util.Collections;
+
+import net.sf.briar.api.clock.SystemClock;
+import net.sf.briar.api.db.DatabaseComponent;
+import net.sf.briar.api.db.DbException;
+import net.sf.briar.api.lifecycle.ShutdownManager;
+import net.sf.briar.api.protocol.PacketFactory;
+import net.sf.briar.db.DatabaseCleaner.Callback;
+
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.junit.Test;
+
+/**
+ * Tests that use the DatabaseCleaner.Callback interface of
+ * DatabaseComponentImpl.
+ */
+public class DatabaseComponentImplTest extends DatabaseComponentTest {
+
+	@Test
+	public void testNotCleanedIfEnoughFreeSpace() throws DbException {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		context.checking(new Expectations() {{
+			oneOf(database).getFreeSpace();
+			will(returnValue(MIN_FREE_SPACE));
+		}});
+		Callback db = createDatabaseComponentImpl(database, cleaner, shutdown,
+				packetFactory);
+
+		db.checkFreeSpaceAndClean();
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testCleanedIfNotEnoughFreeSpace() throws DbException {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		context.checking(new Expectations() {{
+			oneOf(database).getFreeSpace();
+			will(returnValue(MIN_FREE_SPACE - 1));
+			oneOf(database).startTransaction();
+			will(returnValue(txn));
+			oneOf(database).getOldMessages(txn, BYTES_PER_SWEEP);
+			will(returnValue(Collections.emptyList()));
+			oneOf(database).commitTransaction(txn);
+			// As if by magic, some free space has appeared
+			oneOf(database).getFreeSpace();
+			will(returnValue(MIN_FREE_SPACE));
+		}});
+		Callback db = createDatabaseComponentImpl(database, cleaner, shutdown,
+				packetFactory);
+
+		db.checkFreeSpaceAndClean();
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testExpiringUnsendableMessageDoesNotTriggerBackwardInclusion()
+	throws DbException {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		context.checking(new Expectations() {{
+			oneOf(database).getFreeSpace();
+			will(returnValue(MIN_FREE_SPACE - 1));
+			oneOf(database).startTransaction();
+			will(returnValue(txn));
+			oneOf(database).getOldMessages(txn, BYTES_PER_SWEEP);
+			will(returnValue(Collections.singletonList(messageId)));
+			oneOf(database).getSendability(txn, messageId);
+			will(returnValue(0));
+			oneOf(database).removeMessage(txn, messageId);
+			oneOf(database).commitTransaction(txn);
+			oneOf(database).getFreeSpace();
+			will(returnValue(MIN_FREE_SPACE));
+		}});
+		Callback db = createDatabaseComponentImpl(database, cleaner, shutdown,
+				packetFactory);
+
+		db.checkFreeSpaceAndClean();
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testExpiringSendableMessageTriggersBackwardInclusion()
+	throws DbException {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		context.checking(new Expectations() {{
+			oneOf(database).getFreeSpace();
+			will(returnValue(MIN_FREE_SPACE - 1));
+			oneOf(database).startTransaction();
+			will(returnValue(txn));
+			oneOf(database).getOldMessages(txn, BYTES_PER_SWEEP);
+			will(returnValue(Collections.singletonList(messageId)));
+			oneOf(database).getSendability(txn, messageId);
+			will(returnValue(1));
+			oneOf(database).getGroupMessageParent(txn, messageId);
+			will(returnValue(null));
+			oneOf(database).removeMessage(txn, messageId);
+			oneOf(database).commitTransaction(txn);
+			oneOf(database).getFreeSpace();
+			will(returnValue(MIN_FREE_SPACE));
+		}});
+		Callback db = createDatabaseComponentImpl(database, cleaner, shutdown,
+				packetFactory);
+
+		db.checkFreeSpaceAndClean();
+
+		context.assertIsSatisfied();
+	}
+
+	@Override
+	protected <T> DatabaseComponent createDatabaseComponent(
+			Database<T> database, DatabaseCleaner cleaner,
+			ShutdownManager shutdown, PacketFactory packetFactory) {
+		return createDatabaseComponentImpl(database, cleaner, shutdown,
+				packetFactory);
+	}
+
+	private <T> DatabaseComponentImpl<T> createDatabaseComponentImpl(
+			Database<T> database, DatabaseCleaner cleaner,
+			ShutdownManager shutdown, PacketFactory packetFactory) {
+		return new DatabaseComponentImpl<T>(database, cleaner, shutdown,
+				packetFactory, new SystemClock());
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/db/DatabaseComponentTest.java b/briar-tests/src/net/sf/briar/db/DatabaseComponentTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..73a4545dfde556f8858c778506420c38130803ca
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/db/DatabaseComponentTest.java
@@ -0,0 +1,1606 @@
+package net.sf.briar.db;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.BitSet;
+import java.util.Collection;
+import java.util.Collections;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.TestUtils;
+import net.sf.briar.api.ContactId;
+import net.sf.briar.api.Rating;
+import net.sf.briar.api.TransportProperties;
+import net.sf.briar.api.db.DatabaseComponent;
+import net.sf.briar.api.db.NoSuchContactException;
+import net.sf.briar.api.db.NoSuchContactTransportException;
+import net.sf.briar.api.db.event.ContactAddedEvent;
+import net.sf.briar.api.db.event.ContactRemovedEvent;
+import net.sf.briar.api.db.event.DatabaseListener;
+import net.sf.briar.api.db.event.MessagesAddedEvent;
+import net.sf.briar.api.db.event.RatingChangedEvent;
+import net.sf.briar.api.db.event.SubscriptionsUpdatedEvent;
+import net.sf.briar.api.lifecycle.ShutdownManager;
+import net.sf.briar.api.protocol.Ack;
+import net.sf.briar.api.protocol.AuthorId;
+import net.sf.briar.api.protocol.Batch;
+import net.sf.briar.api.protocol.BatchId;
+import net.sf.briar.api.protocol.Group;
+import net.sf.briar.api.protocol.GroupId;
+import net.sf.briar.api.protocol.Message;
+import net.sf.briar.api.protocol.MessageId;
+import net.sf.briar.api.protocol.Offer;
+import net.sf.briar.api.protocol.PacketFactory;
+import net.sf.briar.api.protocol.RawBatch;
+import net.sf.briar.api.protocol.Request;
+import net.sf.briar.api.protocol.SubscriptionUpdate;
+import net.sf.briar.api.protocol.Transport;
+import net.sf.briar.api.protocol.TransportId;
+import net.sf.briar.api.protocol.TransportUpdate;
+import net.sf.briar.api.transport.ContactTransport;
+import net.sf.briar.api.transport.TemporarySecret;
+
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.junit.Test;
+
+public abstract class DatabaseComponentTest extends BriarTestCase {
+
+	protected final Object txn = new Object();
+	protected final AuthorId authorId;
+	protected final BatchId batchId;
+	protected final ContactId contactId;
+	protected final GroupId groupId;
+	protected final MessageId messageId, parentId;
+	private final String subject;
+	private final long timestamp;
+	private final int size;
+	private final byte[] raw;
+	private final Message message, privateMessage;
+	private final Group group;
+	private final TransportId transportId;
+	private final Collection<Transport> transports;
+	private final ContactTransport contactTransport;
+	private final TemporarySecret temporarySecret;
+
+	public DatabaseComponentTest() {
+		super();
+		authorId = new AuthorId(TestUtils.getRandomId());
+		batchId = new BatchId(TestUtils.getRandomId());
+		contactId = new ContactId(234);
+		groupId = new GroupId(TestUtils.getRandomId());
+		messageId = new MessageId(TestUtils.getRandomId());
+		parentId = new MessageId(TestUtils.getRandomId());
+		subject = "Foo";
+		timestamp = System.currentTimeMillis();
+		size = 1234;
+		raw = new byte[size];
+		message = new TestMessage(messageId, null, groupId, authorId, subject,
+				timestamp, raw);
+		privateMessage = new TestMessage(messageId, null, null, null, subject,
+				timestamp, raw);
+		group = new TestGroup(groupId, "The really exciting group", null);
+		transportId = new TransportId(TestUtils.getRandomId());
+		TransportProperties properties = new TransportProperties(
+				Collections.singletonMap("foo", "bar"));
+		Transport transport = new Transport(transportId, properties);
+		transports = Collections.singletonList(transport);
+		contactTransport = new ContactTransport(contactId, transportId, 123L,
+				234L, 345L, true);
+		temporarySecret = new TemporarySecret(contactId, transportId, 1L, 2L,
+				3L, false, 4L, new byte[32], 5L, 6L, new byte[4]);
+	}
+
+	protected abstract <T> DatabaseComponent createDatabaseComponent(
+			Database<T> database, DatabaseCleaner cleaner,
+			ShutdownManager shutdown, PacketFactory packetFactory);
+
+	@Test
+	@SuppressWarnings("unchecked")
+	public void testSimpleCalls() throws Exception {
+		final int shutdownHandle = 12345;
+		Mockery context = new Mockery();
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final Group group = context.mock(Group.class);
+		final DatabaseListener listener = context.mock(DatabaseListener.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			// open(false)
+			oneOf(database).open(false);
+			oneOf(cleaner).startCleaning(
+					with(any(DatabaseCleaner.Callback.class)),
+					with(any(long.class)));
+			oneOf(shutdown).addShutdownHook(with(any(Runnable.class)));
+			will(returnValue(shutdownHandle));
+			// getRating(authorId)
+			oneOf(database).getRating(txn, authorId);
+			will(returnValue(Rating.UNRATED));
+			// setRating(authorId, Rating.GOOD)
+			oneOf(database).setRating(txn, authorId, Rating.GOOD);
+			will(returnValue(Rating.UNRATED));
+			oneOf(database).getMessagesByAuthor(txn, authorId);
+			will(returnValue(Collections.emptyList()));
+			oneOf(listener).eventOccurred(with(any(RatingChangedEvent.class)));
+			// setRating(authorId, Rating.GOOD) again
+			oneOf(database).setRating(txn, authorId, Rating.GOOD);
+			will(returnValue(Rating.GOOD));
+			// addContact()
+			oneOf(database).addContact(txn);
+			will(returnValue(contactId));
+			oneOf(listener).eventOccurred(with(any(ContactAddedEvent.class)));
+			// getContacts()
+			oneOf(database).getContacts(txn);
+			will(returnValue(Collections.singletonList(contactId)));
+			// getTransportProperties(transportId)
+			oneOf(database).getRemoteProperties(txn, transportId);
+			will(returnValue(Collections.emptyMap()));
+			// subscribe(group)
+			oneOf(group).getId();
+			will(returnValue(groupId));
+			oneOf(database).containsSubscription(txn, groupId);
+			will(returnValue(false));
+			oneOf(database).addSubscription(txn, group);
+			// subscribe(group) again
+			oneOf(group).getId();
+			will(returnValue(groupId));
+			oneOf(database).containsSubscription(txn, groupId);
+			will(returnValue(true));
+			// getMessageHeaders(groupId)
+			oneOf(database).getMessageHeaders(txn, groupId);
+			will(returnValue(Collections.emptyList()));
+			// getSubscriptions()
+			oneOf(database).getSubscriptions(txn);
+			will(returnValue(Collections.singletonList(groupId)));
+			// unsubscribe(groupId)
+			oneOf(database).containsSubscription(txn, groupId);
+			will(returnValue(true));
+			oneOf(database).getVisibility(txn, groupId);
+			will(returnValue(Collections.emptyList()));
+			oneOf(database).removeSubscription(txn, groupId);
+			// unsubscribe(groupId) again
+			oneOf(database).containsSubscription(txn, groupId);
+			will(returnValue(false));
+			// removeContact(contactId)
+			oneOf(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			oneOf(database).removeContact(txn, contactId);
+			oneOf(listener).eventOccurred(with(any(ContactRemovedEvent.class)));
+			// close()
+			oneOf(shutdown).removeShutdownHook(shutdownHandle);
+			oneOf(cleaner).stopCleaning();
+			oneOf(database).close();
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.open(false);
+		db.addListener(listener);
+		assertEquals(Rating.UNRATED, db.getRating(authorId));
+		db.setRating(authorId, Rating.GOOD); // First time - listeners called
+		db.setRating(authorId, Rating.GOOD); // Second time - not called
+		assertEquals(contactId, db.addContact());
+		assertEquals(Collections.singletonList(contactId), db.getContacts());
+		assertEquals(Collections.emptyMap(),
+				db.getRemoteProperties(transportId));
+		db.subscribe(group); // First time - listeners called
+		db.subscribe(group); // Second time - not called
+		assertEquals(Collections.emptyList(), db.getMessageHeaders(groupId));
+		assertEquals(Collections.singletonList(groupId), db.getSubscriptions());
+		db.unsubscribe(groupId); // First time - listeners called
+		db.unsubscribe(groupId); // Second time - not called
+		db.removeContact(contactId);
+		db.removeListener(listener);
+		db.close();
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testNullParentStopsBackwardInclusion() throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		context.checking(new Expectations() {{
+			// setRating(authorId, Rating.GOOD)
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			oneOf(database).setRating(txn, authorId, Rating.GOOD);
+			will(returnValue(Rating.UNRATED));
+			// The sendability of the author's messages should be incremented
+			oneOf(database).getMessagesByAuthor(txn, authorId);
+			will(returnValue(Collections.singletonList(messageId)));
+			oneOf(database).getSendability(txn, messageId);
+			will(returnValue(0));
+			oneOf(database).setSendability(txn, messageId, 1);
+			// Backward inclusion stops when the message has no parent
+			oneOf(database).getGroupMessageParent(txn, messageId);
+			will(returnValue(null));
+			oneOf(database).commitTransaction(txn);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.setRating(authorId, Rating.GOOD);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testUnaffectedParentStopsBackwardInclusion() throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		context.checking(new Expectations() {{
+			// setRating(authorId, Rating.GOOD)
+			oneOf(database).startTransaction();
+			will(returnValue(txn));
+			oneOf(database).setRating(txn, authorId, Rating.GOOD);
+			will(returnValue(Rating.UNRATED));
+			// The sendability of the author's messages should be incremented
+			oneOf(database).getMessagesByAuthor(txn, authorId);
+			will(returnValue(Collections.singletonList(messageId)));
+			oneOf(database).getSendability(txn, messageId);
+			will(returnValue(0));
+			oneOf(database).setSendability(txn, messageId, 1);
+			// The parent exists, is in the DB, and is in the same group
+			oneOf(database).getGroupMessageParent(txn, messageId);
+			will(returnValue(parentId));
+			// The parent is already sendable
+			oneOf(database).getSendability(txn, parentId);
+			will(returnValue(1));
+			oneOf(database).setSendability(txn, parentId, 2);
+			oneOf(database).commitTransaction(txn);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.setRating(authorId, Rating.GOOD);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testAffectedParentContinuesBackwardInclusion()
+			throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		context.checking(new Expectations() {{
+			// setRating(authorId, Rating.GOOD)
+			oneOf(database).startTransaction();
+			will(returnValue(txn));
+			oneOf(database).setRating(txn, authorId, Rating.GOOD);
+			will(returnValue(Rating.UNRATED));
+			// The sendability of the author's messages should be incremented
+			oneOf(database).getMessagesByAuthor(txn, authorId);
+			will(returnValue(Collections.singletonList(messageId)));
+			oneOf(database).getSendability(txn, messageId);
+			will(returnValue(0));
+			oneOf(database).setSendability(txn, messageId, 1);
+			// The parent exists, is in the DB, and is in the same group
+			oneOf(database).getGroupMessageParent(txn, messageId);
+			will(returnValue(parentId));
+			// The parent is not already sendable
+			oneOf(database).getSendability(txn, parentId);
+			will(returnValue(0));
+			oneOf(database).setSendability(txn, parentId, 1);
+			// The parent has no parent
+			oneOf(database).getGroupMessageParent(txn, parentId);
+			will(returnValue(null));
+			oneOf(database).commitTransaction(txn);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.setRating(authorId, Rating.GOOD);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testGroupMessagesAreNotStoredUnlessSubscribed()
+			throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		context.checking(new Expectations() {{
+			// addLocalGroupMessage(message)
+			oneOf(database).startTransaction();
+			will(returnValue(txn));
+			oneOf(database).containsSubscription(txn, groupId, timestamp);
+			will(returnValue(false));
+			oneOf(database).commitTransaction(txn);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.addLocalGroupMessage(message);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testDuplicateGroupMessagesAreNotStored() throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		context.checking(new Expectations() {{
+			// addLocalGroupMessage(message)
+			oneOf(database).startTransaction();
+			will(returnValue(txn));
+			oneOf(database).containsSubscription(txn, groupId, timestamp);
+			will(returnValue(true));
+			oneOf(database).addGroupMessage(txn, message);
+			will(returnValue(false));
+			oneOf(database).commitTransaction(txn);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.addLocalGroupMessage(message);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testAddLocalGroupMessage() throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		context.checking(new Expectations() {{
+			// addLocalGroupMessage(message)
+			oneOf(database).startTransaction();
+			will(returnValue(txn));
+			oneOf(database).containsSubscription(txn, groupId, timestamp);
+			will(returnValue(true));
+			oneOf(database).addGroupMessage(txn, message);
+			will(returnValue(true));
+			oneOf(database).getContacts(txn);
+			will(returnValue(Collections.singletonList(contactId)));
+			oneOf(database).setStatus(txn, contactId, messageId, Status.NEW);
+			// The author is unrated and there are no sendable children
+			oneOf(database).getRating(txn, authorId);
+			will(returnValue(Rating.UNRATED));
+			oneOf(database).getNumberOfSendableChildren(txn, messageId);
+			will(returnValue(0));
+			oneOf(database).setSendability(txn, messageId, 0);
+			oneOf(database).commitTransaction(txn);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.addLocalGroupMessage(message);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testAddingSendableMessageTriggersBackwardInclusion()
+			throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		context.checking(new Expectations() {{
+			// addLocalGroupMessage(message)
+			oneOf(database).startTransaction();
+			will(returnValue(txn));
+			oneOf(database).containsSubscription(txn, groupId, timestamp);
+			will(returnValue(true));
+			oneOf(database).addGroupMessage(txn, message);
+			will(returnValue(true));
+			oneOf(database).getContacts(txn);
+			will(returnValue(Collections.singletonList(contactId)));
+			oneOf(database).setStatus(txn, contactId, messageId, Status.NEW);
+			// The author is rated GOOD and there are two sendable children
+			oneOf(database).getRating(txn, authorId);
+			will(returnValue(Rating.GOOD));
+			oneOf(database).getNumberOfSendableChildren(txn, messageId);
+			will(returnValue(2));
+			oneOf(database).setSendability(txn, messageId, 3);
+			// The sendability of the message's ancestors should be updated
+			oneOf(database).getGroupMessageParent(txn, messageId);
+			will(returnValue(null));
+			oneOf(database).commitTransaction(txn);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.addLocalGroupMessage(message);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testDuplicatePrivateMessagesAreNotStored() throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// addLocalPrivateMessage(privateMessage, contactId)
+			oneOf(database).addPrivateMessage(txn, privateMessage, contactId);
+			will(returnValue(false));
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.addLocalPrivateMessage(privateMessage, contactId);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testAddLocalPrivateMessage() throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// addLocalPrivateMessage(privateMessage, contactId)
+			oneOf(database).addPrivateMessage(txn, privateMessage, contactId);
+			will(returnValue(true));
+			oneOf(database).setStatus(txn, contactId, messageId, Status.NEW);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.addLocalPrivateMessage(privateMessage, contactId);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testVariousMethodsThrowExceptionIfContactIsMissing()
+			throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final Ack ack = context.mock(Ack.class);
+		final Batch batch = context.mock(Batch.class);
+		final Offer offer = context.mock(Offer.class);
+		final SubscriptionUpdate subscriptionUpdate =
+				context.mock(SubscriptionUpdate.class);
+		final TransportUpdate transportUpdate =
+				context.mock(TransportUpdate.class);
+		context.checking(new Expectations() {{
+			// Check whether the contact is in the DB (which it's not)
+			exactly(16).of(database).startTransaction();
+			will(returnValue(txn));
+			exactly(16).of(database).containsContact(txn, contactId);
+			will(returnValue(false));
+			exactly(16).of(database).abortTransaction(txn);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		try {
+			db.addContactTransport(contactTransport);
+			fail();
+		} catch(NoSuchContactException expected) {}
+
+		try {
+			db.addLocalPrivateMessage(privateMessage, contactId);
+			fail();
+		} catch(NoSuchContactException expected) {}
+
+		try {
+			db.generateAck(contactId, 123);
+			fail();
+		} catch(NoSuchContactException expected) {}
+
+		try {
+			db.generateBatch(contactId, 123);
+			fail();
+		} catch(NoSuchContactException expected) {}
+
+		try {
+			db.generateBatch(contactId, 123,
+					Collections.<MessageId>emptyList());
+			fail();
+		} catch(NoSuchContactException expected) {}
+
+		try {
+			db.generateOffer(contactId, 123);
+			fail();
+		} catch(NoSuchContactException expected) {}
+
+		try {
+			db.generateSubscriptionUpdate(contactId);
+			fail();
+		} catch(NoSuchContactException expected) {}
+
+		try {
+			db.generateTransportUpdate(contactId);
+			fail();
+		} catch(NoSuchContactException expected) {}
+
+		try {
+			db.hasSendableMessages(contactId);
+			fail();
+		} catch(NoSuchContactException expected) {}
+
+		try {
+			db.receiveAck(contactId, ack);
+			fail();
+		} catch(NoSuchContactException expected) {}
+
+		try {
+			db.receiveBatch(contactId, batch);
+			fail();
+		} catch(NoSuchContactException expected) {}
+
+		try {
+			db.receiveOffer(contactId, offer);
+			fail();
+		} catch(NoSuchContactException expected) {}
+
+		try {
+			db.receiveSubscriptionUpdate(contactId, subscriptionUpdate);
+			fail();
+		} catch(NoSuchContactException expected) {}
+
+		try {
+			db.receiveTransportUpdate(contactId, transportUpdate);
+			fail();
+		} catch(NoSuchContactException expected) {}
+
+		try {
+			db.removeContact(contactId);
+			fail();
+		} catch(NoSuchContactException expected) {}
+
+		try {
+			db.setSeen(contactId, Collections.singletonList(messageId));
+			fail();
+		} catch(NoSuchContactException expected) {}
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testVariousMethodsThrowExceptionIfContactTransportIsMissing()
+			throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		context.checking(new Expectations() {{
+			// Check whether the contact transport is in the DB (which it's not)
+			exactly(2).of(database).startTransaction();
+			will(returnValue(txn));
+			exactly(2).of(database).containsContactTransport(txn, contactId,
+					transportId);
+			will(returnValue(false));
+			exactly(2).of(database).abortTransaction(txn);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		try {
+			db.incrementConnectionCounter(contactId, transportId, 0L);
+			fail();
+		} catch(NoSuchContactTransportException expected) {}
+
+		try {
+			db.setConnectionWindow(contactId, transportId, 0L, 0L, new byte[4]);
+			fail();
+		} catch(NoSuchContactTransportException expected) {}
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testGenerateAck() throws Exception {
+		final BatchId batchId1 = new BatchId(TestUtils.getRandomId());
+		final Collection<BatchId> batchesToAck = new ArrayList<BatchId>();
+		batchesToAck.add(batchId);
+		batchesToAck.add(batchId1);
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final Ack ack = context.mock(Ack.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// Get the batches to ack
+			oneOf(database).getBatchesToAck(txn, contactId, 123);
+			will(returnValue(batchesToAck));
+			// Create the packet
+			oneOf(packetFactory).createAck(batchesToAck);
+			will(returnValue(ack));
+			// Record the batches that were acked
+			oneOf(database).removeBatchesToAck(txn, contactId, batchesToAck);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		assertEquals(ack, db.generateAck(contactId, 123));
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testGenerateBatch() throws Exception {
+		final MessageId messageId1 = new MessageId(TestUtils.getRandomId());
+		final byte[] raw1 = new byte[size];
+		final Collection<MessageId> sendable = Arrays.asList(messageId,
+				messageId1);
+		final Collection<byte[]> messages = Arrays.asList(raw, raw1);
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final RawBatch batch = context.mock(RawBatch.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// Get the sendable messages
+			oneOf(database).getSendableMessages(txn, contactId, size * 2);
+			will(returnValue(sendable));
+			oneOf(database).getMessage(txn, messageId);
+			will(returnValue(raw));
+			oneOf(database).getMessage(txn, messageId1);
+			will(returnValue(raw1));
+			// Create the packet
+			oneOf(packetFactory).createBatch(messages);
+			will(returnValue(batch));
+			// Record the outstanding batch
+			oneOf(batch).getId();
+			will(returnValue(batchId));
+			oneOf(database).addOutstandingBatch(txn, contactId, batchId,
+					sendable);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		assertEquals(batch, db.generateBatch(contactId, size * 2));
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testGenerateBatchFromRequest() throws Exception {
+		final MessageId messageId1 = new MessageId(TestUtils.getRandomId());
+		final MessageId messageId2 = new MessageId(TestUtils.getRandomId());
+		final byte[] raw1 = new byte[size];
+		final Collection<MessageId> requested = new ArrayList<MessageId>();
+		requested.add(messageId);
+		requested.add(messageId1);
+		requested.add(messageId2);
+		final Collection<byte[]> msgs = Arrays.asList(raw1);
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final RawBatch batch = context.mock(RawBatch.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// Try to get the requested messages
+			oneOf(database).getMessageIfSendable(txn, contactId, messageId);
+			will(returnValue(null)); // Message is not sendable
+			oneOf(database).getMessageIfSendable(txn, contactId, messageId1);
+			will(returnValue(raw1)); // Message is sendable
+			oneOf(database).getMessageIfSendable(txn, contactId, messageId2);
+			will(returnValue(null)); // Message is not sendable
+			// Create the packet
+			oneOf(packetFactory).createBatch(msgs);
+			will(returnValue(batch));
+			// Record the outstanding batch
+			oneOf(batch).getId();
+			will(returnValue(batchId));
+			oneOf(database).addOutstandingBatch(txn, contactId, batchId,
+					Collections.singletonList(messageId1));
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		assertEquals(batch, db.generateBatch(contactId, size * 3, requested));
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testGenerateOffer() throws Exception {
+		final MessageId messageId1 = new MessageId(TestUtils.getRandomId());
+		final Collection<MessageId> offerable = new ArrayList<MessageId>();
+		offerable.add(messageId);
+		offerable.add(messageId1);
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final Offer offer = context.mock(Offer.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// Get the sendable message IDs
+			oneOf(database).getOfferableMessages(txn, contactId, 123);
+			will(returnValue(offerable));
+			// Create the packet
+			oneOf(packetFactory).createOffer(offerable);
+			will(returnValue(offer));
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		assertEquals(offer, db.generateOffer(contactId, 123));
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testGenerateSubscriptionUpdate() throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final SubscriptionUpdate subscriptionUpdate =
+				context.mock(SubscriptionUpdate.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// Get the visible holes and subscriptions
+			oneOf(database).getVisibleHoles(with(txn), with(contactId),
+					with(any(long.class)));
+			will(returnValue(Collections.emptyMap()));
+			oneOf(database).getVisibleSubscriptions(with(txn), with(contactId),
+					with(any(long.class)));
+			will(returnValue(Collections.singletonMap(group, 0L)));
+			// Get the expiry time
+			oneOf(database).getExpiryTime(txn);
+			will(returnValue(0L));
+			// Create the packet
+			oneOf(packetFactory).createSubscriptionUpdate(
+					with(Collections.<GroupId, GroupId>emptyMap()),
+					with(Collections.singletonMap(group, 0L)),
+					with(any(long.class)),
+					with(any(long.class)));
+			will(returnValue(subscriptionUpdate));
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		assertEquals(subscriptionUpdate,
+				db.generateSubscriptionUpdate(contactId));
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testTransportUpdateNotSentUnlessDue() throws Exception {
+		final long now = System.currentTimeMillis();
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// Check whether an update is due
+			oneOf(database).getTransportsModified(txn);
+			will(returnValue(now - 1L));
+			oneOf(database).getTransportsSent(txn, contactId);
+			will(returnValue(now));
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		assertNull(db.generateTransportUpdate(contactId));
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testGenerateTransportUpdate() throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final TransportUpdate transportUpdate =
+				context.mock(TransportUpdate.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// Check whether an update is due
+			oneOf(database).getTransportsModified(txn);
+			will(returnValue(0L));
+			oneOf(database).getTransportsSent(txn, contactId);
+			will(returnValue(0L));
+			// Get the local transport properties
+			oneOf(database).getLocalTransports(txn);
+			will(returnValue(transports));
+			oneOf(database).setTransportsSent(with(txn), with(contactId),
+					with(any(long.class)));
+			// Create the packet
+			oneOf(packetFactory).createTransportUpdate(with(transports),
+					with(any(long.class)));
+			will(returnValue(transportUpdate));
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		assertEquals(transportUpdate, db.generateTransportUpdate(contactId));
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testReceiveAck() throws Exception {
+		final BatchId batchId1 = new BatchId(TestUtils.getRandomId());
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final Ack ack = context.mock(Ack.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// Get the acked batches
+			oneOf(ack).getBatchIds();
+			will(returnValue(Collections.singletonList(batchId)));
+			oneOf(database).removeAckedBatch(txn, contactId, batchId);
+			// Find lost batches
+			oneOf(database).getLostBatches(txn, contactId);
+			will(returnValue(Collections.singletonList(batchId1)));
+			oneOf(database).removeLostBatch(txn, contactId, batchId1);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.receiveAck(contactId, ack);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testReceiveBatchStoresPrivateMessage() throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final Batch batch = context.mock(Batch.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			oneOf(batch).getMessages();
+			will(returnValue(Collections.singletonList(privateMessage)));
+			// The message is stored
+			oneOf(database).addPrivateMessage(txn, privateMessage, contactId);
+			will(returnValue(true));
+			oneOf(database).setStatus(txn, contactId, messageId, Status.SEEN);
+			// The batch must be acked
+			oneOf(batch).getId();
+			will(returnValue(batchId));
+			oneOf(database).addBatchToAck(txn, contactId, batchId);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.receiveBatch(contactId, batch);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testReceiveBatchWithDuplicatePrivateMessage() throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final Batch batch = context.mock(Batch.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			oneOf(batch).getMessages();
+			will(returnValue(Collections.singletonList(privateMessage)));
+			// The message is stored, but it's a duplicate
+			oneOf(database).addPrivateMessage(txn, privateMessage, contactId);
+			will(returnValue(false));
+			// The batch must still be acked
+			oneOf(batch).getId();
+			will(returnValue(batchId));
+			oneOf(database).addBatchToAck(txn, contactId, batchId);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.receiveBatch(contactId, batch);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testReceiveBatchDoesNotStoreGroupMessageUnlessSubscribed()
+			throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final Batch batch = context.mock(Batch.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// Only store messages belonging to visible, subscribed groups
+			oneOf(batch).getMessages();
+			will(returnValue(Collections.singletonList(message)));
+			oneOf(database).containsVisibleSubscription(txn, groupId,
+					contactId, timestamp);
+			will(returnValue(false));
+			// The message is not stored but the batch must still be acked
+			oneOf(batch).getId();
+			will(returnValue(batchId));
+			oneOf(database).addBatchToAck(txn, contactId, batchId);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.receiveBatch(contactId, batch);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testReceiveBatchDoesNotCalculateSendabilityForDuplicates()
+			throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final Batch batch = context.mock(Batch.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// Only store messages belonging to visible, subscribed groups
+			oneOf(batch).getMessages();
+			will(returnValue(Collections.singletonList(message)));
+			oneOf(database).containsVisibleSubscription(txn, groupId,
+					contactId, timestamp);
+			will(returnValue(true));
+			// The message is stored, but it's a duplicate
+			oneOf(database).addGroupMessage(txn, message);
+			will(returnValue(false));
+			oneOf(database).setStatus(txn, contactId, messageId, Status.SEEN);
+			// The batch needs to be acknowledged
+			oneOf(batch).getId();
+			will(returnValue(batchId));
+			oneOf(database).addBatchToAck(txn, contactId, batchId);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.receiveBatch(contactId, batch);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testReceiveBatchCalculatesSendability() throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final Batch batch = context.mock(Batch.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// Only store messages belonging to visible, subscribed groups
+			oneOf(batch).getMessages();
+			will(returnValue(Collections.singletonList(message)));
+			oneOf(database).containsVisibleSubscription(txn, groupId,
+					contactId, timestamp);
+			will(returnValue(true));
+			// The message is stored, and it's not a duplicate
+			oneOf(database).addGroupMessage(txn, message);
+			will(returnValue(true));
+			oneOf(database).setStatus(txn, contactId, messageId, Status.SEEN);
+			// Set the status to NEW for all other contacts (there are none)
+			oneOf(database).getContacts(txn);
+			will(returnValue(Collections.singletonList(contactId)));
+			// Calculate the sendability - zero, so ancestors aren't updated
+			oneOf(database).getRating(txn, authorId);
+			will(returnValue(Rating.UNRATED));
+			oneOf(database).getNumberOfSendableChildren(txn, messageId);
+			will(returnValue(0));
+			oneOf(database).setSendability(txn, messageId, 0);
+			// The batch needs to be acknowledged
+			oneOf(batch).getId();
+			will(returnValue(batchId));
+			oneOf(database).addBatchToAck(txn, contactId, batchId);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.receiveBatch(contactId, batch);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testReceiveBatchUpdatesAncestorSendability() throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final Batch batch = context.mock(Batch.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// Only store messages belonging to visible, subscribed groups
+			oneOf(batch).getMessages();
+			will(returnValue(Collections.singletonList(message)));
+			oneOf(database).containsVisibleSubscription(txn, groupId,
+					contactId, timestamp);
+			will(returnValue(true));
+			// The message is stored, and it's not a duplicate
+			oneOf(database).addGroupMessage(txn, message);
+			will(returnValue(true));
+			oneOf(database).setStatus(txn, contactId, messageId, Status.SEEN);
+			// Set the status to NEW for all other contacts (there are none)
+			oneOf(database).getContacts(txn);
+			will(returnValue(Collections.singletonList(contactId)));
+			// Calculate the sendability - ancestors are updated
+			oneOf(database).getRating(txn, authorId);
+			will(returnValue(Rating.GOOD));
+			oneOf(database).getNumberOfSendableChildren(txn, messageId);
+			will(returnValue(1));
+			oneOf(database).setSendability(txn, messageId, 2);
+			oneOf(database).getGroupMessageParent(txn, messageId);
+			will(returnValue(null));
+			// The batch needs to be acknowledged
+			oneOf(batch).getId();
+			will(returnValue(batchId));
+			oneOf(database).addBatchToAck(txn, contactId, batchId);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.receiveBatch(contactId, batch);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testReceiveOffer() throws Exception {
+		final MessageId messageId1 = new MessageId(TestUtils.getRandomId());
+		final MessageId messageId2 = new MessageId(TestUtils.getRandomId());
+		final Collection<MessageId> offered = new ArrayList<MessageId>();
+		offered.add(messageId);
+		offered.add(messageId1);
+		offered.add(messageId2);
+		final BitSet expectedRequest = new BitSet(3);
+		expectedRequest.set(0);
+		expectedRequest.set(2);
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final Offer offer = context.mock(Offer.class);
+		final Request request = context.mock(Request.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// Get the offered messages
+			oneOf(offer).getMessageIds();
+			will(returnValue(offered));
+			oneOf(database).setStatusSeenIfVisible(txn, contactId, messageId);
+			will(returnValue(false)); // Not visible - request message # 0
+			oneOf(database).setStatusSeenIfVisible(txn, contactId, messageId1);
+			will(returnValue(true)); // Visible - do not request message # 1
+			oneOf(database).setStatusSeenIfVisible(txn, contactId, messageId2);
+			will(returnValue(false)); // Not visible - request message # 2
+			// Create the packet
+			oneOf(packetFactory).createRequest(expectedRequest, 3);
+			will(returnValue(request));
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		assertEquals(request, db.receiveOffer(contactId, offer));
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testReceiveSubscriptionUpdate() throws Exception {
+		final GroupId start = new GroupId(TestUtils.getRandomId());
+		final GroupId end = new GroupId(TestUtils.getRandomId());
+		final long expiry = 1234L, timestamp = 5678L;
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final SubscriptionUpdate subscriptionUpdate =
+				context.mock(SubscriptionUpdate.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// Get the contents of the update
+			oneOf(subscriptionUpdate).getHoles();
+			will(returnValue(Collections.singletonMap(start, end)));
+			oneOf(subscriptionUpdate).getSubscriptions();
+			will(returnValue(Collections.singletonMap(group, 0L)));
+			oneOf(subscriptionUpdate).getExpiryTime();
+			will(returnValue(expiry));
+			oneOf(subscriptionUpdate).getTimestamp();
+			will(returnValue(timestamp));
+			// Store the contents of the update
+			oneOf(database).removeSubscriptions(txn, contactId, start, end);
+			oneOf(database).addSubscription(txn, contactId, group, 0L);
+			oneOf(database).setExpiryTime(txn, contactId, expiry);
+			oneOf(database).setSubscriptionsReceived(txn, contactId, timestamp);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.receiveSubscriptionUpdate(contactId, subscriptionUpdate);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testReceiveTransportUpdate() throws Exception {
+		final long timestamp = 1234L;
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final TransportUpdate transportUpdate =
+				context.mock(TransportUpdate.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// Get the contents of the update
+			oneOf(transportUpdate).getTransports();
+			will(returnValue(transports));
+			oneOf(transportUpdate).getTimestamp();
+			will(returnValue(timestamp));
+			oneOf(database).setTransports(txn, contactId, transports,
+					timestamp);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.receiveTransportUpdate(contactId, transportUpdate);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testAddingGroupMessageCallsListeners() throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final DatabaseListener listener = context.mock(DatabaseListener.class);
+		context.checking(new Expectations() {{
+			// addLocalGroupMessage(message)
+			oneOf(database).startTransaction();
+			will(returnValue(txn));
+			oneOf(database).containsSubscription(txn, groupId, timestamp);
+			will(returnValue(true));
+			oneOf(database).addGroupMessage(txn, message);
+			will(returnValue(true));
+			oneOf(database).getContacts(txn);
+			will(returnValue(Collections.singletonList(contactId)));
+			oneOf(database).setStatus(txn, contactId, messageId, Status.NEW);
+			oneOf(database).getRating(txn, authorId);
+			will(returnValue(Rating.UNRATED));
+			oneOf(database).getNumberOfSendableChildren(txn, messageId);
+			will(returnValue(0));
+			oneOf(database).setSendability(txn, messageId, 0);
+			oneOf(database).commitTransaction(txn);
+			// The message was added, so the listener should be called
+			oneOf(listener).eventOccurred(with(any(MessagesAddedEvent.class)));
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.addListener(listener);
+		db.addLocalGroupMessage(message);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testAddingPrivateMessageCallsListeners() throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final DatabaseListener listener = context.mock(DatabaseListener.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// addLocalPrivateMessage(privateMessage, contactId)
+			oneOf(database).addPrivateMessage(txn, privateMessage, contactId);
+			will(returnValue(true));
+			oneOf(database).setStatus(txn, contactId, messageId, Status.NEW);
+			// The message was added, so the listener should be called
+			oneOf(listener).eventOccurred(with(any(MessagesAddedEvent.class)));
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.addListener(listener);
+		db.addLocalPrivateMessage(privateMessage, contactId);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testAddingDuplicateGroupMessageDoesNotCallListeners()
+			throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final DatabaseListener listener = context.mock(DatabaseListener.class);
+		context.checking(new Expectations() {{
+			// addLocalGroupMessage(message)
+			oneOf(database).startTransaction();
+			will(returnValue(txn));
+			oneOf(database).containsSubscription(txn, groupId, timestamp);
+			will(returnValue(true));
+			oneOf(database).addGroupMessage(txn, message);
+			will(returnValue(false));
+			oneOf(database).commitTransaction(txn);
+			// The message was not added, so the listener should not be called
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.addListener(listener);
+		db.addLocalGroupMessage(message);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testAddingDuplicatePrivateMessageDoesNotCallListeners()
+			throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final DatabaseListener listener = context.mock(DatabaseListener.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// addLocalPrivateMessage(privateMessage, contactId)
+			oneOf(database).addPrivateMessage(txn, privateMessage, contactId);
+			will(returnValue(false));
+			// The message was not added, so the listener should not be called
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.addListener(listener);
+		db.addLocalPrivateMessage(privateMessage, contactId);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testTransportPropertiesChangedCallsListeners()
+			throws Exception {
+		final TransportProperties properties =
+				new TransportProperties(Collections.singletonMap("bar", "baz"));
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		context.checking(new Expectations() {{
+			oneOf(database).startTransaction();
+			will(returnValue(txn));
+			oneOf(database).getLocalProperties(txn, transportId);
+			will(returnValue(new TransportProperties()));
+			oneOf(database).mergeLocalProperties(txn, transportId, properties);
+			oneOf(database).setTransportsModified(with(txn),
+					with(any(long.class)));
+			oneOf(database).commitTransaction(txn);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.mergeLocalProperties(transportId, properties);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testTransportPropertiesUnchangedDoesNotCallListeners()
+			throws Exception {
+		final TransportProperties properties =
+				new TransportProperties(Collections.singletonMap("bar", "baz"));
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final DatabaseListener listener = context.mock(DatabaseListener.class);
+		context.checking(new Expectations() {{
+			oneOf(database).startTransaction();
+			will(returnValue(txn));
+			oneOf(database).getLocalProperties(txn, transportId);
+			will(returnValue(properties));
+			oneOf(database).commitTransaction(txn);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.addListener(listener);
+		db.mergeLocalProperties(transportId, properties);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testSetSeen() throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		context.checking(new Expectations() {{
+			allowing(database).startTransaction();
+			will(returnValue(txn));
+			allowing(database).commitTransaction(txn);
+			allowing(database).containsContact(txn, contactId);
+			will(returnValue(true));
+			// setSeen(contactId, Collections.singletonList(messageId))
+			oneOf(database).setStatusSeenIfVisible(txn, contactId, messageId);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.setSeen(contactId, Collections.singletonList(messageId));
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testVisibilityChangedCallsListeners() throws Exception {
+		final ContactId contactId1 = new ContactId(123);
+		final Collection<ContactId> both = Arrays.asList(contactId, contactId1);
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final DatabaseListener listener = context.mock(DatabaseListener.class);
+		context.checking(new Expectations() {{
+			oneOf(database).startTransaction();
+			will(returnValue(txn));
+			oneOf(database).getVisibility(txn, groupId);
+			will(returnValue(both));
+			oneOf(database).getContacts(txn);
+			will(returnValue(both));
+			oneOf(database).removeVisibility(txn, contactId1, groupId);
+			oneOf(database).commitTransaction(txn);
+			oneOf(listener).eventOccurred(with(any(
+					SubscriptionsUpdatedEvent.class)));
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.addListener(listener);
+		db.setVisibility(groupId, Collections.singletonList(contactId));
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testVisibilityUnchangedDoesNotCallListeners() throws Exception {
+		final ContactId contactId1 = new ContactId(234);
+		final Collection<ContactId> both = Arrays.asList(contactId, contactId1);
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		final DatabaseListener listener = context.mock(DatabaseListener.class);
+		context.checking(new Expectations() {{
+			oneOf(database).startTransaction();
+			will(returnValue(txn));
+			oneOf(database).getVisibility(txn, groupId);
+			will(returnValue(both));
+			oneOf(database).getContacts(txn);
+			will(returnValue(both));
+			oneOf(database).commitTransaction(txn);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.addListener(listener);
+		db.setVisibility(groupId, both);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testTemporarySecrets() throws Exception {
+		Mockery context = new Mockery();
+		@SuppressWarnings("unchecked")
+		final Database<Object> database = context.mock(Database.class);
+		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
+		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		context.checking(new Expectations() {{
+			// addSecrets()
+			oneOf(database).startTransaction();
+			will(returnValue(txn));
+			oneOf(database).containsContactTransport(txn, contactId,
+					transportId);
+			will(returnValue(true));
+			oneOf(database).addSecrets(txn,
+					Collections.singletonList(temporarySecret));
+			oneOf(database).commitTransaction(txn);
+			// getSecrets()
+			oneOf(database).startTransaction();
+			will(returnValue(txn));
+			oneOf(database).getSecrets(txn);
+			will(returnValue(Collections.singletonList(temporarySecret)));
+			oneOf(database).commitTransaction(txn);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown, packetFactory);
+
+		db.addSecrets(Collections.singletonList(temporarySecret));
+		assertEquals(Collections.singletonList(temporarySecret),
+				db.getSecrets());
+
+		context.assertIsSatisfied();
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/db/H2DatabaseTest.java b/briar-tests/src/net/sf/briar/db/H2DatabaseTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..842caa434eded9133a4434b48470bf7c494c9d5e
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/db/H2DatabaseTest.java
@@ -0,0 +1,2044 @@
+package net.sf.briar.db;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static net.sf.briar.db.DatabaseConstants.RETRANSMIT_THRESHOLD;
+import static org.junit.Assert.assertArrayEquals;
+
+import java.io.File;
+import java.sql.Connection;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.TestDatabaseConfig;
+import net.sf.briar.TestUtils;
+import net.sf.briar.api.ContactId;
+import net.sf.briar.api.Rating;
+import net.sf.briar.api.TransportConfig;
+import net.sf.briar.api.TransportProperties;
+import net.sf.briar.api.clock.SystemClock;
+import net.sf.briar.api.db.DbException;
+import net.sf.briar.api.db.MessageHeader;
+import net.sf.briar.api.protocol.AuthorId;
+import net.sf.briar.api.protocol.BatchId;
+import net.sf.briar.api.protocol.Group;
+import net.sf.briar.api.protocol.GroupFactory;
+import net.sf.briar.api.protocol.GroupId;
+import net.sf.briar.api.protocol.Message;
+import net.sf.briar.api.protocol.MessageId;
+import net.sf.briar.api.protocol.Transport;
+import net.sf.briar.api.protocol.TransportId;
+import net.sf.briar.api.transport.ContactTransport;
+import net.sf.briar.api.transport.TemporarySecret;
+
+import org.apache.commons.io.FileSystemUtils;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class H2DatabaseTest extends BriarTestCase {
+
+	private static final int ONE_MEGABYTE = 1024 * 1024;
+	private static final int MAX_SIZE = 5 * ONE_MEGABYTE;
+
+	private final File testDir = TestUtils.getTestDirectory();
+	private final Random random = new Random();
+	private final GroupFactory groupFactory;
+	private final Group group;
+	private final AuthorId authorId;
+	private final BatchId batchId;
+	private final ContactId contactId;
+	private final GroupId groupId;
+	private final MessageId messageId, privateMessageId;
+	private final String subject;
+	private final long timestamp;
+	private final int size;
+	private final byte[] raw;
+	private final Message message, privateMessage;
+	private final TransportId transportId;
+
+	public H2DatabaseTest() throws Exception {
+		super();
+		groupFactory = new TestGroupFactory();
+		authorId = new AuthorId(TestUtils.getRandomId());
+		batchId = new BatchId(TestUtils.getRandomId());
+		contactId = new ContactId(1);
+		groupId = new GroupId(TestUtils.getRandomId());
+		messageId = new MessageId(TestUtils.getRandomId());
+		privateMessageId = new MessageId(TestUtils.getRandomId());
+		group = new TestGroup(groupId, "Foo", null);
+		subject = "Foo";
+		timestamp = System.currentTimeMillis();
+		size = 1234;
+		raw = new byte[size];
+		random.nextBytes(raw);
+		message = new TestMessage(messageId, null, groupId, authorId, subject,
+				timestamp, raw);
+		privateMessage = new TestMessage(privateMessageId, null, null, null,
+				subject, timestamp, raw);
+		transportId = new TransportId(TestUtils.getRandomId());
+	}
+
+	@Before
+	public void setUp() {
+		testDir.mkdirs();
+	}
+
+	@Test
+	public void testPersistence() throws Exception {
+		// Store some records
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+		assertFalse(db.containsContact(txn, contactId));
+		assertEquals(contactId, db.addContact(txn));
+		assertTrue(db.containsContact(txn, contactId));
+		assertFalse(db.containsSubscription(txn, groupId));
+		db.addSubscription(txn, group);
+		assertTrue(db.containsSubscription(txn, groupId));
+		assertFalse(db.containsMessage(txn, messageId));
+		db.addGroupMessage(txn, message);
+		assertTrue(db.containsMessage(txn, messageId));
+		assertFalse(db.containsMessage(txn, privateMessageId));
+		db.addPrivateMessage(txn, privateMessage, contactId);
+		assertTrue(db.containsMessage(txn, privateMessageId));
+		db.commitTransaction(txn);
+		db.close();
+
+		// Check that the records are still there
+		db = open(true);
+		txn = db.startTransaction();
+		assertTrue(db.containsContact(txn, contactId));
+		assertTrue(db.containsSubscription(txn, groupId));
+		assertTrue(db.containsMessage(txn, messageId));
+		byte[] raw1 = db.getMessage(txn, messageId);
+		assertArrayEquals(raw, raw1);
+		assertTrue(db.containsMessage(txn, privateMessageId));
+		raw1 = db.getMessage(txn, privateMessageId);
+		assertArrayEquals(raw, raw1);
+		// Delete the records
+		db.removeMessage(txn, messageId);
+		db.removeMessage(txn, privateMessageId);
+		db.removeContact(txn, contactId);
+		db.removeSubscription(txn, groupId);
+		db.commitTransaction(txn);
+		db.close();
+
+		// Check that the records are gone
+		db = open(true);
+		txn = db.startTransaction();
+		assertFalse(db.containsContact(txn, contactId));
+		assertEquals(Collections.emptyMap(),
+				db.getRemoteProperties(txn, transportId));
+		assertFalse(db.containsSubscription(txn, groupId));
+		assertFalse(db.containsMessage(txn, messageId));
+		assertFalse(db.containsMessage(txn, privateMessageId));
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testContactIdsIncrease() throws Exception {
+		ContactId contactId1 = new ContactId(2);
+		ContactId contactId2 = new ContactId(3);
+		ContactId contactId3 = new ContactId(4);
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Create three contacts
+		assertFalse(db.containsContact(txn, contactId));
+		assertEquals(contactId, db.addContact(txn));
+		assertTrue(db.containsContact(txn, contactId));
+		assertFalse(db.containsContact(txn, contactId1));
+		assertEquals(contactId1, db.addContact(txn));
+		assertTrue(db.containsContact(txn, contactId1));
+		assertFalse(db.containsContact(txn, contactId2));
+		assertEquals(contactId2, db.addContact(txn));
+		assertTrue(db.containsContact(txn, contactId2));
+		// Delete the contact with the highest ID
+		db.removeContact(txn, contactId2);
+		assertFalse(db.containsContact(txn, contactId2));
+		// Add another contact - a new ID should be created
+		assertFalse(db.containsContact(txn, contactId3));
+		assertEquals(contactId3, db.addContact(txn));
+		assertTrue(db.containsContact(txn, contactId3));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testRatings() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Unknown authors should be unrated
+		assertEquals(Rating.UNRATED, db.getRating(txn, authorId));
+		// Store a rating
+		db.setRating(txn, authorId, Rating.GOOD);
+		// Check that the rating was stored
+		assertEquals(Rating.GOOD, db.getRating(txn, authorId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testUnsubscribingRemovesGroupMessage() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Subscribe to a group and store a message
+		db.addSubscription(txn, group);
+		db.addGroupMessage(txn, message);
+
+		// Unsubscribing from the group should remove the message
+		assertTrue(db.containsMessage(txn, messageId));
+		db.removeSubscription(txn, groupId);
+		assertFalse(db.containsMessage(txn, messageId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testRemovingContactRemovesPrivateMessage() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact and store a private message
+		assertEquals(contactId, db.addContact(txn));
+		db.addPrivateMessage(txn, privateMessage, contactId);
+
+		// Removing the contact should remove the message
+		assertTrue(db.containsMessage(txn, privateMessageId));
+		db.removeContact(txn, contactId);
+		assertFalse(db.containsMessage(txn, privateMessageId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testSendablePrivateMessagesMustHaveStatusNew()
+			throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact and store a private message
+		assertEquals(contactId, db.addContact(txn));
+		db.addPrivateMessage(txn, privateMessage, contactId);
+
+		// The message has no status yet, so it should not be sendable
+		assertFalse(db.hasSendableMessages(txn, contactId));
+		Iterator<MessageId> it =
+				db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertFalse(it.hasNext());
+
+		// Changing the status to NEW should make the message sendable
+		db.setStatus(txn, contactId, privateMessageId, Status.NEW);
+		assertTrue(db.hasSendableMessages(txn, contactId));
+		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertTrue(it.hasNext());
+		assertEquals(privateMessageId, it.next());
+		assertFalse(it.hasNext());
+
+		// Changing the status to SENT should make the message unsendable
+		db.setStatus(txn, contactId, privateMessageId, Status.SENT);
+		assertFalse(db.hasSendableMessages(txn, contactId));
+		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertFalse(it.hasNext());
+
+		// Changing the status to SEEN should also make the message unsendable
+		db.setStatus(txn, contactId, privateMessageId, Status.SEEN);
+		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertFalse(it.hasNext());
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testSendablePrivateMessagesMustFitCapacity()
+			throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact and store a private message
+		assertEquals(contactId, db.addContact(txn));
+		db.addPrivateMessage(txn, privateMessage, contactId);
+		db.setStatus(txn, contactId, privateMessageId, Status.NEW);
+
+		// The message is sendable, but too large to send
+		assertTrue(db.hasSendableMessages(txn, contactId));
+		Iterator<MessageId> it =
+				db.getSendableMessages(txn, contactId, size - 1).iterator();
+		assertFalse(it.hasNext());
+
+		// The message is just the right size to send
+		assertTrue(db.hasSendableMessages(txn, contactId));
+		it = db.getSendableMessages(txn, contactId, size).iterator();
+		assertTrue(it.hasNext());
+		assertEquals(privateMessageId, it.next());
+		assertFalse(it.hasNext());
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testSendableGroupMessagesMustHavePositiveSendability()
+			throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact, subscribe to a group and store a message
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		db.addVisibility(txn, contactId, groupId);
+		db.addSubscription(txn, contactId, group, 0L);
+		db.addGroupMessage(txn, message);
+		db.setStatus(txn, contactId, messageId, Status.NEW);
+
+		// The message should not be sendable
+		assertFalse(db.hasSendableMessages(txn, contactId));
+		Iterator<MessageId> it =
+				db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertFalse(it.hasNext());
+
+		// Changing the sendability to > 0 should make the message sendable
+		db.setSendability(txn, messageId, 1);
+		assertTrue(db.hasSendableMessages(txn, contactId));
+		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertTrue(it.hasNext());
+		assertEquals(messageId, it.next());
+		assertFalse(it.hasNext());
+
+		// Changing the sendability to 0 should make the message unsendable
+		db.setSendability(txn, messageId, 0);
+		assertFalse(db.hasSendableMessages(txn, contactId));
+		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertFalse(it.hasNext());
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testSendableGroupMessagesMustHaveStatusNew()
+			throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact, subscribe to a group and store a message
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		db.addVisibility(txn, contactId, groupId);
+		db.addSubscription(txn, contactId, group, 0L);
+		db.addGroupMessage(txn, message);
+		db.setSendability(txn, messageId, 1);
+
+		// The message has no status yet, so it should not be sendable
+		assertFalse(db.hasSendableMessages(txn, contactId));
+		Iterator<MessageId> it =
+				db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertFalse(it.hasNext());
+
+		// Changing the status to Status.NEW should make the message sendable
+		db.setStatus(txn, contactId, messageId, Status.NEW);
+		assertTrue(db.hasSendableMessages(txn, contactId));
+		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertTrue(it.hasNext());
+		assertEquals(messageId, it.next());
+		assertFalse(it.hasNext());
+
+		// Changing the status to SENT should make the message unsendable
+		db.setStatus(txn, contactId, messageId, Status.SENT);
+		assertFalse(db.hasSendableMessages(txn, contactId));
+		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertFalse(it.hasNext());
+
+		// Changing the status to SEEN should also make the message unsendable
+		db.setStatus(txn, contactId, messageId, Status.SEEN);
+		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertFalse(it.hasNext());
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testSendableGroupMessagesMustBeSubscribed() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact, subscribe to a group and store a message
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		db.addVisibility(txn, contactId, groupId);
+		db.addGroupMessage(txn, message);
+		db.setSendability(txn, messageId, 1);
+		db.setStatus(txn, contactId, messageId, Status.NEW);
+
+		// The contact is not subscribed, so the message should not be sendable
+		assertFalse(db.hasSendableMessages(txn, contactId));
+		Iterator<MessageId> it =
+				db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertFalse(it.hasNext());
+
+		// The contact subscribing should make the message sendable
+		db.addSubscription(txn, contactId, group, 0L);
+		assertTrue(db.hasSendableMessages(txn, contactId));
+		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertTrue(it.hasNext());
+		assertEquals(messageId, it.next());
+		assertFalse(it.hasNext());
+
+		// The contact unsubscribing should make the message unsendable
+		db.removeSubscriptions(txn, contactId, null, null);
+		assertFalse(db.hasSendableMessages(txn, contactId));
+		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertFalse(it.hasNext());
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testSendableGroupMessagesMustBeNewerThanSubscriptions()
+			throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact, subscribe to a group and store a message
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		db.addVisibility(txn, contactId, groupId);
+		db.addGroupMessage(txn, message);
+		db.setSendability(txn, messageId, 1);
+		db.setStatus(txn, contactId, messageId, Status.NEW);
+
+		// The message is older than the contact's subscription, so it should
+		// not be sendable
+		db.addSubscription(txn, contactId, group, timestamp + 1);
+		assertFalse(db.hasSendableMessages(txn, contactId));
+		Iterator<MessageId> it =
+				db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertFalse(it.hasNext());
+
+		// Changing the contact's subscription should make the message sendable
+		db.removeSubscriptions(txn, contactId, null, null);
+		db.addSubscription(txn, contactId, group, timestamp);
+		assertTrue(db.hasSendableMessages(txn, contactId));
+		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertTrue(it.hasNext());
+		assertEquals(messageId, it.next());
+		assertFalse(it.hasNext());
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testSendableGroupMessagesMustFitCapacity() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact, subscribe to a group and store a message
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		db.addVisibility(txn, contactId, groupId);
+		db.addSubscription(txn, contactId, group, 0L);
+		db.addGroupMessage(txn, message);
+		db.setSendability(txn, messageId, 1);
+		db.setStatus(txn, contactId, messageId, Status.NEW);
+
+		// The message is sendable, but too large to send
+		assertTrue(db.hasSendableMessages(txn, contactId));
+		Iterator<MessageId> it =
+				db.getSendableMessages(txn, contactId, size - 1).iterator();
+		assertFalse(it.hasNext());
+
+		// The message is just the right size to send
+		assertTrue(db.hasSendableMessages(txn, contactId));
+		it = db.getSendableMessages(txn, contactId, size).iterator();
+		assertTrue(it.hasNext());
+		assertEquals(messageId, it.next());
+		assertFalse(it.hasNext());
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testSendableGroupMessagesMustBeVisible() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact, subscribe to a group and store a message
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		db.addSubscription(txn, contactId, group, 0L);
+		db.addGroupMessage(txn, message);
+		db.setSendability(txn, messageId, 1);
+		db.setStatus(txn, contactId, messageId, Status.NEW);
+
+		// The subscription is not visible to the contact, so the message
+		// should not be sendable
+		assertFalse(db.hasSendableMessages(txn, contactId));
+		Iterator<MessageId> it =
+				db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertFalse(it.hasNext());
+
+		// Making the subscription visible should make the message sendable
+		db.addVisibility(txn, contactId, groupId);
+		assertTrue(db.hasSendableMessages(txn, contactId));
+		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertTrue(it.hasNext());
+		assertEquals(messageId, it.next());
+		assertFalse(it.hasNext());
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testBatchesToAck() throws Exception {
+		BatchId batchId1 = new BatchId(TestUtils.getRandomId());
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact and some batches to ack
+		assertEquals(contactId, db.addContact(txn));
+		db.addBatchToAck(txn, contactId, batchId);
+		db.addBatchToAck(txn, contactId, batchId1);
+
+		// Both batch IDs should be returned
+		Collection<BatchId> acks = db.getBatchesToAck(txn, contactId, 1234);
+		assertEquals(2, acks.size());
+		assertTrue(acks.contains(batchId));
+		assertTrue(acks.contains(batchId1));
+
+		// Remove the batch IDs
+		db.removeBatchesToAck(txn, contactId, acks);
+
+		// Both batch IDs should have been removed
+		acks = db.getBatchesToAck(txn, contactId, 1234);
+		assertEquals(0, acks.size());
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testDuplicateBatchesReceived() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact and receive the same batch twice
+		assertEquals(contactId, db.addContact(txn));
+		db.addBatchToAck(txn, contactId, batchId);
+		db.addBatchToAck(txn, contactId, batchId);
+
+		// The batch ID should only be returned once
+		Collection<BatchId> acks = db.getBatchesToAck(txn, contactId, 1234);
+		assertEquals(1, acks.size());
+		assertTrue(acks.contains(batchId));
+
+		// Remove the batch ID
+		db.removeBatchesToAck(txn, contactId, acks);
+
+		// The batch ID should have been removed
+		acks = db.getBatchesToAck(txn, contactId, 1234);
+		assertEquals(0, acks.size());
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testSameBatchCannotBeSentTwice() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact, subscribe to a group and store a message
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		db.addGroupMessage(txn, message);
+
+		// Add an outstanding batch
+		db.addOutstandingBatch(txn, contactId, batchId,
+				Collections.singletonList(messageId));
+
+		// It should not be possible to add the same outstanding batch again
+		try {
+			db.addOutstandingBatch(txn, contactId, batchId,
+					Collections.singletonList(messageId));
+			fail();
+		} catch(DbException expected) {}
+
+		db.abortTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testSameBatchCanBeSentToDifferentContacts() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add two contacts, subscribe to a group and store a message
+		assertEquals(contactId, db.addContact(txn));
+		ContactId contactId1 = db.addContact(txn);
+		db.addSubscription(txn, group);
+		db.addGroupMessage(txn, message);
+
+		// Add an outstanding batch for the first contact
+		db.addOutstandingBatch(txn, contactId, batchId,
+				Collections.singletonList(messageId));
+
+		// Add the same outstanding batch for the second contact
+		db.addOutstandingBatch(txn, contactId1, batchId,
+				Collections.singletonList(messageId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testRemoveAckedBatch() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact, subscribe to a group and store a message
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		db.addVisibility(txn, contactId, groupId);
+		db.addSubscription(txn, contactId, group, 0L);
+		db.addGroupMessage(txn, message);
+		db.setSendability(txn, messageId, 1);
+		db.setStatus(txn, contactId, messageId, Status.NEW);
+
+		// Retrieve the message from the database and mark it as sent
+		Iterator<MessageId> it =
+				db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertTrue(it.hasNext());
+		assertEquals(messageId, it.next());
+		assertFalse(it.hasNext());
+		db.setStatus(txn, contactId, messageId, Status.SENT);
+		db.addOutstandingBatch(txn, contactId, batchId,
+				Collections.singletonList(messageId));
+
+		// The message should no longer be sendable
+		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertFalse(it.hasNext());
+
+		// Pretend that the batch was acked
+		db.removeAckedBatch(txn, contactId, batchId);
+
+		// The message still should not be sendable
+		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertFalse(it.hasNext());
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testRemoveLostBatch() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact, subscribe to a group and store a message
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		db.addVisibility(txn, contactId, groupId);
+		db.addSubscription(txn, contactId, group, 0L);
+		db.addGroupMessage(txn, message);
+		db.setSendability(txn, messageId, 1);
+		db.setStatus(txn, contactId, messageId, Status.NEW);
+
+		// Get the message and mark it as sent
+		Iterator<MessageId> it =
+				db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertTrue(it.hasNext());
+		assertEquals(messageId, it.next());
+		assertFalse(it.hasNext());
+		db.setStatus(txn, contactId, messageId, Status.SENT);
+		db.addOutstandingBatch(txn, contactId, batchId,
+				Collections.singletonList(messageId));
+
+		// The message should no longer be sendable
+		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertFalse(it.hasNext());
+
+		// Pretend that the batch was lost
+		db.removeLostBatch(txn, contactId, batchId);
+
+		// The message should be sendable again
+		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
+		assertTrue(it.hasNext());
+		assertEquals(messageId, it.next());
+		assertFalse(it.hasNext());
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testRetransmission() throws Exception {
+		BatchId[] ids = new BatchId[RETRANSMIT_THRESHOLD + 5];
+		for(int i = 0; i < ids.length; i++) {
+			ids[i] = new BatchId(TestUtils.getRandomId());
+		}
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact
+		assertEquals(contactId, db.addContact(txn));
+
+		// Add some outstanding batches, a few ms apart
+		for(int i = 0; i < ids.length; i++) {
+			db.addOutstandingBatch(txn, contactId, ids[i],
+					Collections.<MessageId>emptyList());
+			Thread.sleep(5);
+		}
+
+		// The contact acks the batches in reverse order. The first
+		// RETRANSMIT_THRESHOLD - 1 acks should not trigger any retransmissions
+		for(int i = 0; i < RETRANSMIT_THRESHOLD - 1; i++) {
+			db.removeAckedBatch(txn, contactId, ids[ids.length - i - 1]);
+			Collection<BatchId> lost = db.getLostBatches(txn, contactId);
+			assertEquals(Collections.emptyList(), lost);
+		}
+
+		// The next ack should trigger the retransmission of the remaining
+		// five outstanding batches
+		int index = ids.length - RETRANSMIT_THRESHOLD;
+		db.removeAckedBatch(txn, contactId, ids[index]);
+		Collection<BatchId> lost = db.getLostBatches(txn, contactId);
+		for(int i = 0; i < index; i++) {
+			assertTrue(lost.contains(ids[i]));
+		}
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testNoRetransmission() throws Exception {
+		BatchId[] ids = new BatchId[RETRANSMIT_THRESHOLD * 2];
+		for(int i = 0; i < ids.length; i++) {
+			ids[i] = new BatchId(TestUtils.getRandomId());
+		}
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact
+		assertEquals(contactId, db.addContact(txn));
+
+		// Add some outstanding batches, a few ms apart
+		for(int i = 0; i < ids.length; i++) {
+			db.addOutstandingBatch(txn, contactId, ids[i],
+					Collections.<MessageId>emptyList());
+			Thread.sleep(5);
+		}
+
+		// The contact acks the batches in the order they were sent - nothing
+		// should be retransmitted
+		for(int i = 0; i < ids.length; i++) {
+			db.removeAckedBatch(txn, contactId, ids[i]);
+			Collection<BatchId> lost = db.getLostBatches(txn, contactId);
+			assertEquals(Collections.emptyList(), lost);
+		}
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testGetMessagesByAuthor() throws Exception {
+		AuthorId authorId1 = new AuthorId(TestUtils.getRandomId());
+		MessageId messageId1 = new MessageId(TestUtils.getRandomId());
+		Message message1 = new TestMessage(messageId1, null, groupId, authorId1,
+				subject, timestamp, raw);
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Subscribe to a group and store two messages
+		db.addSubscription(txn, group);
+		db.addGroupMessage(txn, message);
+		db.addGroupMessage(txn, message1);
+
+		// Check that each message is retrievable via its author
+		Iterator<MessageId> it =
+				db.getMessagesByAuthor(txn, authorId).iterator();
+		assertTrue(it.hasNext());
+		assertEquals(messageId, it.next());
+		assertFalse(it.hasNext());
+		it = db.getMessagesByAuthor(txn, authorId1).iterator();
+		assertTrue(it.hasNext());
+		assertEquals(messageId1, it.next());
+		assertFalse(it.hasNext());
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testGetNumberOfSendableChildren() throws Exception {
+		MessageId childId1 = new MessageId(TestUtils.getRandomId());
+		MessageId childId2 = new MessageId(TestUtils.getRandomId());
+		MessageId childId3 = new MessageId(TestUtils.getRandomId());
+		GroupId groupId1 = new GroupId(TestUtils.getRandomId());
+		Group group1 = groupFactory.createGroup(groupId1, "Another group name",
+				null);
+		Message child1 = new TestMessage(childId1, messageId, groupId,
+				authorId, subject, timestamp, raw);
+		Message child2 = new TestMessage(childId2, messageId, groupId,
+				authorId, subject, timestamp, raw);
+		// The third child is in a different group
+		Message child3 = new TestMessage(childId3, messageId, groupId1,
+				authorId, subject, timestamp, raw);
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Subscribe to the groups and store the messages
+		db.addSubscription(txn, group);
+		db.addSubscription(txn, group1);
+		db.addGroupMessage(txn, message);
+		db.addGroupMessage(txn, child1);
+		db.addGroupMessage(txn, child2);
+		db.addGroupMessage(txn, child3);
+		// Make all the children sendable
+		db.setSendability(txn, childId1, 1);
+		db.setSendability(txn, childId2, 5);
+		db.setSendability(txn, childId3, 3);
+
+		// There should be two sendable children
+		assertEquals(2, db.getNumberOfSendableChildren(txn, messageId));
+		// Make one of the children unsendable
+		db.setSendability(txn, childId1, 0);
+		// Now there should be one sendable child
+		assertEquals(1, db.getNumberOfSendableChildren(txn, messageId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testGetOldMessages() throws Exception {
+		MessageId messageId1 = new MessageId(TestUtils.getRandomId());
+		Message message1 = new TestMessage(messageId1, null, groupId, authorId,
+				subject, timestamp + 1000, raw);
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Subscribe to a group and store two messages
+		db.addSubscription(txn, group);
+		db.addGroupMessage(txn, message);
+		db.addGroupMessage(txn, message1);
+
+		// Allowing enough capacity for one message should return the older one
+		Iterator<MessageId> it = db.getOldMessages(txn, size).iterator();
+		assertTrue(it.hasNext());
+		assertEquals(messageId, it.next());
+		assertFalse(it.hasNext());
+
+		// Allowing enough capacity for both messages should return both
+		Collection<MessageId> ids = new HashSet<MessageId>();
+		for(MessageId id : db.getOldMessages(txn, size * 2)) ids.add(id);
+		assertEquals(2, ids.size());
+		assertTrue(ids.contains(messageId));
+		assertTrue(ids.contains(messageId1));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testGetFreeSpace() throws Exception {
+		byte[] largeBody = new byte[ONE_MEGABYTE];
+		for(int i = 0; i < largeBody.length; i++) largeBody[i] = (byte) i;
+		Message message1 = new TestMessage(messageId, null, groupId, authorId,
+				subject, timestamp, largeBody);
+		Database<Connection> db = open(false);
+
+		// Sanity check: there should be enough space on disk for this test
+		String path = testDir.getAbsolutePath();
+		assertTrue(FileSystemUtils.freeSpaceKb(path) * 1024L > MAX_SIZE);
+
+		// The free space should not be more than the allowed maximum size
+		long free = db.getFreeSpace();
+		assertTrue(free <= MAX_SIZE);
+		assertTrue(free > 0);
+
+		// Storing a message should reduce the free space
+		Connection txn = db.startTransaction();
+		db.addSubscription(txn, group);
+		db.addGroupMessage(txn, message1);
+		db.commitTransaction(txn);
+		assertTrue(db.getFreeSpace() < free);
+
+		db.close();
+	}
+
+	@Test
+	public void testCloseWaitsForCommit() throws Exception {
+		final CountDownLatch closing = new CountDownLatch(1);
+		final CountDownLatch closed = new CountDownLatch(1);
+		final AtomicBoolean transactionFinished = new AtomicBoolean(false);
+		final AtomicBoolean error = new AtomicBoolean(false);
+		final Database<Connection> db = open(false);
+
+		// Start a transaction
+		Connection txn = db.startTransaction();
+		// In another thread, close the database
+		Thread close = new Thread() {
+			public void run() {
+				try {
+					closing.countDown();
+					db.close();
+					if(!transactionFinished.get()) error.set(true);
+					closed.countDown();
+				} catch(Exception e) {
+					error.set(true);
+				}
+			}
+		};
+		close.start();
+		closing.await();
+		// Do whatever the transaction needs to do
+		Thread.sleep(10);
+		transactionFinished.set(true);
+		// Commit the transaction
+		db.commitTransaction(txn);
+		// The other thread should now terminate
+		assertTrue(closed.await(5, SECONDS));
+		// Check that the other thread didn't encounter an error
+		assertFalse(error.get());
+	}
+
+	@Test
+	public void testCloseWaitsForAbort() throws Exception {
+		final CountDownLatch closing = new CountDownLatch(1);
+		final CountDownLatch closed = new CountDownLatch(1);
+		final AtomicBoolean transactionFinished = new AtomicBoolean(false);
+		final AtomicBoolean error = new AtomicBoolean(false);
+		final Database<Connection> db = open(false);
+
+		// Start a transaction
+		Connection txn = db.startTransaction();
+		// In another thread, close the database
+		Thread close = new Thread() {
+			public void run() {
+				try {
+					closing.countDown();
+					db.close();
+					if(!transactionFinished.get()) error.set(true);
+					closed.countDown();
+				} catch(Exception e) {
+					error.set(true);
+				}
+			}
+		};
+		close.start();
+		closing.await();
+		// Do whatever the transaction needs to do
+		Thread.sleep(10);
+		transactionFinished.set(true);
+		// Abort the transaction
+		db.abortTransaction(txn);
+		// The other thread should now terminate
+		assertTrue(closed.await(5, SECONDS));
+		// Check that the other thread didn't encounter an error
+		assertFalse(error.get());
+	}
+
+	@Test
+	public void testUpdateTransportProperties() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact with a transport
+		TransportProperties properties =
+				new TransportProperties(Collections.singletonMap("foo", "bar"));
+		Transport transport = new Transport(transportId, properties);
+		assertEquals(contactId, db.addContact(txn));
+		db.setTransports(txn, contactId,
+				Collections.singletonList(transport), 1);
+		assertEquals(Collections.singletonMap(contactId, properties),
+				db.getRemoteProperties(txn, transportId));
+
+		// Replace the transport properties
+		TransportProperties properties1 =
+				new TransportProperties(Collections.singletonMap("baz", "bam"));
+		Transport transport1 = new Transport(transportId, properties1);
+		db.setTransports(txn, contactId,
+				Collections.singletonList(transport1), 2);
+		assertEquals(Collections.singletonMap(contactId, properties1),
+				db.getRemoteProperties(txn, transportId));
+
+		// Remove the transport properties
+		TransportProperties properties2 = new TransportProperties();
+		Transport transport2 = new Transport(transportId, properties2);
+		db.setTransports(txn, contactId,
+				Collections.singletonList(transport2), 3);
+		assertEquals(Collections.emptyMap(),
+				db.getRemoteProperties(txn, transportId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testLocalTransports() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Set the transport properties
+		TransportProperties properties = new TransportProperties();
+		properties.put("foo", "foo");
+		properties.put("bar", "bar");
+		db.mergeLocalProperties(txn, transportId, properties);
+		assertEquals(Collections.singletonList(properties),
+				db.getLocalTransports(txn));
+
+		// Update one of the properties and add another
+		TransportProperties properties1 = new TransportProperties();
+		properties1.put("bar", "baz");
+		properties1.put("bam", "bam");
+		db.mergeLocalProperties(txn, transportId, properties1);
+		TransportProperties expected = new TransportProperties();
+		expected.put("foo", "foo");
+		expected.put("bar", "baz");
+		expected.put("bam", "bam");
+		assertEquals(Collections.singletonList(expected),
+				db.getLocalTransports(txn));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testUpdateTransportConfig() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Set the transport config
+		TransportConfig config = new TransportConfig();
+		config.put("foo", "foo");
+		config.put("bar", "bar");
+		db.mergeConfig(txn, transportId, config);
+		assertEquals(config, db.getConfig(txn, transportId));
+
+		// Update one of the properties and add another
+		TransportConfig config1 = new TransportConfig();
+		config1.put("bar", "baz");
+		config1.put("bam", "bam");
+		db.mergeConfig(txn, transportId, config1);
+		TransportConfig expected = new TransportConfig();
+		expected.put("foo", "foo");
+		expected.put("bar", "baz");
+		expected.put("bam", "bam");
+		assertEquals(expected, db.getConfig(txn, transportId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testTransportsNotUpdatedIfTimestampIsOld() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact with a transport
+		TransportProperties properties =
+				new TransportProperties(Collections.singletonMap("foo", "bar"));
+		Transport transport = new Transport(transportId, properties);
+		assertEquals(contactId, db.addContact(txn));
+		db.setTransports(txn, contactId,
+				Collections.singletonList(transport), 1);
+		assertEquals(Collections.singletonMap(contactId, properties),
+				db.getRemoteProperties(txn, transportId));
+
+		// Replace the transport properties using a timestamp of 2
+		TransportProperties properties1 =
+				new TransportProperties(Collections.singletonMap("baz", "bam"));
+		Transport transport1 = new Transport(transportId, properties1);
+		db.setTransports(txn, contactId,
+				Collections.singletonList(transport1), 2);
+		assertEquals(Collections.singletonMap(contactId, properties1),
+				db.getRemoteProperties(txn, transportId));
+
+		// Try to replace the transport properties using a timestamp of 1
+		TransportProperties properties2 =
+				new TransportProperties(Collections.singletonMap("quux", "etc"));
+		Transport transport2 = new Transport(transportId, properties2);
+		db.setTransports(txn, contactId,
+				Collections.singletonList(transport2), 1);
+
+		// The old properties should still be there
+		assertEquals(Collections.singletonMap(contactId, properties1),
+				db.getRemoteProperties(txn, transportId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testGetMessageIfSendableReturnsNullIfNotInDatabase()
+			throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact and subscribe to a group
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		db.addSubscription(txn, contactId, group, 0L);
+
+		// The message is not in the database
+		assertNull(db.getMessageIfSendable(txn, contactId, messageId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testGetMessageIfSendableReturnsNullIfSeen() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact, subscribe to a group and store a message
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		db.addSubscription(txn, contactId, group, 0L);
+		db.addGroupMessage(txn, message);
+
+		// Set the sendability to > 0 and the status to SEEN
+		db.setSendability(txn, messageId, 1);
+		db.setStatus(txn, contactId, messageId, Status.SEEN);
+
+		// The message is not sendable because its status is SEEN
+		assertNull(db.getMessageIfSendable(txn, contactId, messageId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testGetMessageIfSendableReturnsNullIfNotSendable()
+			throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact, subscribe to a group and store a message
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		db.addSubscription(txn, contactId, group, 0L);
+		db.addGroupMessage(txn, message);
+
+		// Set the sendability to 0 and the status to NEW
+		db.setSendability(txn, messageId, 0);
+		db.setStatus(txn, contactId, messageId, Status.NEW);
+
+		// The message is not sendable because its sendability is 0
+		assertNull(db.getMessageIfSendable(txn, contactId, messageId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testGetMessageIfSendableReturnsNullIfOld() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact, subscribe to a group and store a message -
+		// the message is older than the contact's subscription
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		db.addVisibility(txn, contactId, groupId);
+		db.addSubscription(txn, contactId, group, timestamp + 1);
+		db.addGroupMessage(txn, message);
+
+		// Set the sendability to > 0 and the status to NEW
+		db.setSendability(txn, messageId, 1);
+		db.setStatus(txn, contactId, messageId, Status.NEW);
+
+		// The message is not sendable because it's too old
+		assertNull(db.getMessageIfSendable(txn, contactId, messageId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testGetMessageIfSendableReturnsMessage() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact, subscribe to a group and store a message
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		db.addVisibility(txn, contactId, groupId);
+		db.addSubscription(txn, contactId, group, 0L);
+		db.addGroupMessage(txn, message);
+
+		// Set the sendability to > 0 and the status to NEW
+		db.setSendability(txn, messageId, 1);
+		db.setStatus(txn, contactId, messageId, Status.NEW);
+
+		// The message is sendable so it should be returned
+		byte[] b = db.getMessageIfSendable(txn, contactId, messageId);
+		assertArrayEquals(raw, b);
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testSetStatusSeenIfVisibleRequiresMessageInDatabase()
+			throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact and subscribe to a group
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		db.addVisibility(txn, contactId, groupId);
+		db.addSubscription(txn, contactId, group, 0L);
+
+		// The message is not in the database
+		assertFalse(db.setStatusSeenIfVisible(txn, contactId, messageId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testSetStatusSeenIfVisibleRequiresLocalSubscription()
+			throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact with a subscription
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, contactId, group, 0L);
+
+		// There's no local subscription for the group
+		assertFalse(db.setStatusSeenIfVisible(txn, contactId, messageId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testSetStatusSeenIfVisibleRequiresContactSubscription()
+			throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact, subscribe to a group and store a message
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		db.addGroupMessage(txn, message);
+		db.setStatus(txn, contactId, messageId, Status.NEW);
+
+		// There's no contact subscription for the group
+		assertFalse(db.setStatusSeenIfVisible(txn, contactId, messageId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testSetStatusSeenIfVisibleRequiresVisibility()
+			throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact, subscribe to a group and store a message
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		db.addGroupMessage(txn, message);
+		db.addSubscription(txn, contactId, group, 0L);
+		db.setStatus(txn, contactId, messageId, Status.NEW);
+
+		// The subscription is not visible
+		assertFalse(db.setStatusSeenIfVisible(txn, contactId, messageId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testSetStatusSeenIfVisibleReturnsTrueIfAlreadySeen()
+			throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact, subscribe to a group and store a message
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		db.addVisibility(txn, contactId, groupId);
+		db.addSubscription(txn, contactId, group, 0L);
+		db.addGroupMessage(txn, message);
+
+		// The message has already been seen by the contact
+		db.setStatus(txn, contactId, messageId, Status.SEEN);
+
+		assertTrue(db.setStatusSeenIfVisible(txn, contactId, messageId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testSetStatusSeenIfVisibleReturnsTrueIfNew()
+			throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact, subscribe to a group and store a message
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		db.addVisibility(txn, contactId, groupId);
+		db.addSubscription(txn, contactId, group, 0L);
+		db.addGroupMessage(txn, message);
+
+		// The message has not been seen by the contact
+		db.setStatus(txn, contactId, messageId, Status.NEW);
+
+		assertTrue(db.setStatusSeenIfVisible(txn, contactId, messageId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testVisibility() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact and subscribe to a group
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+		// The group should not be visible to the contact
+		assertEquals(Collections.emptyList(), db.getVisibility(txn, groupId));
+		// Make the group visible to the contact
+		db.addVisibility(txn, contactId, groupId);
+		assertEquals(Collections.singletonList(contactId),
+				db.getVisibility(txn, groupId));
+		// Make the group invisible again
+		db.removeVisibility(txn, contactId, groupId);
+		assertEquals(Collections.emptyList(), db.getVisibility(txn, groupId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testGetGroupMessageParentWithNoParent() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Subscribe to a group
+		db.addSubscription(txn, group);
+
+		// A message with no parent should return null
+		MessageId childId = new MessageId(TestUtils.getRandomId());
+		Message child = new TestMessage(childId, null, groupId, null, subject,
+				timestamp, raw);
+		db.addGroupMessage(txn, child);
+		assertTrue(db.containsMessage(txn, childId));
+		assertNull(db.getGroupMessageParent(txn, childId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testGetGroupMessageParentWithAbsentParent() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Subscribe to a group
+		db.addSubscription(txn, group);
+
+		// A message with an absent parent should return null
+		MessageId childId = new MessageId(TestUtils.getRandomId());
+		MessageId parentId = new MessageId(TestUtils.getRandomId());
+		Message child = new TestMessage(childId, parentId, groupId, null,
+				subject, timestamp, raw);
+		db.addGroupMessage(txn, child);
+		assertTrue(db.containsMessage(txn, childId));
+		assertFalse(db.containsMessage(txn, parentId));
+		assertNull(db.getGroupMessageParent(txn, childId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testGetGroupMessageParentWithParentInAnotherGroup()
+			throws Exception {
+		GroupId groupId1 = new GroupId(TestUtils.getRandomId());
+		Group group1 = groupFactory.createGroup(groupId1, "Group name", null);
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Subscribe to two groups
+		db.addSubscription(txn, group);
+		db.addSubscription(txn, group1);
+
+		// A message with a parent in another group should return null
+		MessageId childId = new MessageId(TestUtils.getRandomId());
+		MessageId parentId = new MessageId(TestUtils.getRandomId());
+		Message child = new TestMessage(childId, parentId, groupId, null,
+				subject, timestamp, raw);
+		Message parent = new TestMessage(parentId, null, groupId1, null,
+				subject, timestamp, raw);
+		db.addGroupMessage(txn, child);
+		db.addGroupMessage(txn, parent);
+		assertTrue(db.containsMessage(txn, childId));
+		assertTrue(db.containsMessage(txn, parentId));
+		assertNull(db.getGroupMessageParent(txn, childId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testGetGroupMessageParentWithPrivateParent() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact and subscribe to a group
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+
+		// A message with a private parent should return null
+		MessageId childId = new MessageId(TestUtils.getRandomId());
+		Message child = new TestMessage(childId, privateMessageId, groupId,
+				null, subject, timestamp, raw);
+		db.addGroupMessage(txn, child);
+		db.addPrivateMessage(txn, privateMessage, contactId);
+		assertTrue(db.containsMessage(txn, childId));
+		assertTrue(db.containsMessage(txn, privateMessageId));
+		assertNull(db.getGroupMessageParent(txn, childId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testGetGroupMessageParentWithParentInSameGroup()
+			throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Subscribe to a group
+		db.addSubscription(txn, group);
+
+		// A message with a parent in the same group should return the parent
+		MessageId childId = new MessageId(TestUtils.getRandomId());
+		MessageId parentId = new MessageId(TestUtils.getRandomId());
+		Message child = new TestMessage(childId, parentId, groupId, null,
+				subject, timestamp, raw);
+		Message parent = new TestMessage(parentId, null, groupId, null,
+				subject, timestamp, raw);
+		db.addGroupMessage(txn, child);
+		db.addGroupMessage(txn, parent);
+		assertTrue(db.containsMessage(txn, childId));
+		assertTrue(db.containsMessage(txn, parentId));
+		assertEquals(parentId, db.getGroupMessageParent(txn, childId));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testGetMessageBody() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add a contact and subscribe to a group
+		assertEquals(contactId, db.addContact(txn));
+		db.addSubscription(txn, group);
+
+		// Store a couple of messages
+		int bodyLength = raw.length - 20;
+		Message message1 = new TestMessage(messageId, null, groupId, null,
+				subject, timestamp, raw, 5, bodyLength);
+		Message privateMessage1 = new TestMessage(privateMessageId, null, null,
+				null, subject, timestamp, raw, 10, bodyLength);
+		db.addGroupMessage(txn, message1);
+		db.addPrivateMessage(txn, privateMessage1, contactId);
+
+		// Calculate the expected message bodies
+		byte[] expectedBody = new byte[bodyLength];
+		System.arraycopy(raw, 5, expectedBody, 0, bodyLength);
+		assertFalse(Arrays.equals(expectedBody, new byte[bodyLength]));
+		byte[] expectedBody1 = new byte[bodyLength];
+		System.arraycopy(raw, 10, expectedBody1, 0, bodyLength);
+		System.arraycopy(raw, 10, expectedBody1, 0, bodyLength);
+
+		// Retrieve the raw messages
+		assertArrayEquals(raw, db.getMessage(txn, messageId));
+		assertArrayEquals(raw, db.getMessage(txn, privateMessageId));
+
+		// Retrieve the message bodies
+		byte[] body = db.getMessageBody(txn, messageId);
+		assertArrayEquals(expectedBody, body);
+		byte[] body1 = db.getMessageBody(txn, privateMessageId);
+		assertArrayEquals(expectedBody1, body1);
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testGetMessageHeaders() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Subscribe to a group
+		db.addSubscription(txn, group);
+
+		// Store a couple of messages
+		db.addGroupMessage(txn, message);
+		MessageId messageId1 = new MessageId(TestUtils.getRandomId());
+		MessageId parentId = new MessageId(TestUtils.getRandomId());
+		long timestamp1 = System.currentTimeMillis();
+		Message message1 = new TestMessage(messageId1, parentId, groupId,
+				authorId, subject, timestamp1, raw);
+		db.addGroupMessage(txn, message1);
+		// Mark one of the messages read
+		assertFalse(db.setRead(txn, messageId, true));
+
+		// Retrieve the message headers
+		Collection<MessageHeader> headers = db.getMessageHeaders(txn, groupId);
+		Iterator<MessageHeader> it = headers.iterator();
+		boolean messageFound = false, message1Found = false;
+		// First header (order is undefined)
+		assertTrue(it.hasNext());
+		MessageHeader header = it.next();
+		if(messageId.equals(header.getId())) {
+			assertHeadersMatch(message, header);
+			assertTrue(header.getRead());
+			assertFalse(header.getStarred());
+			messageFound = true;
+		} else if(messageId1.equals(header.getId())) {
+			assertHeadersMatch(message1, header);
+			assertFalse(header.getRead());
+			assertFalse(header.getStarred());
+			message1Found = true;
+		} else {
+			fail();
+		}
+		// Second header
+		assertTrue(it.hasNext());
+		header = it.next();
+		if(messageId.equals(header.getId())) {
+			assertHeadersMatch(message, header);
+			assertTrue(header.getRead());
+			assertFalse(header.getStarred());
+			messageFound = true;
+		} else if(messageId1.equals(header.getId())) {
+			assertHeadersMatch(message1, header);
+			assertFalse(header.getRead());
+			assertFalse(header.getStarred());
+			message1Found = true;
+		} else {
+			fail();
+		}
+		// No more headers
+		assertFalse(it.hasNext());
+		assertTrue(messageFound);
+		assertTrue(message1Found);
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	private void assertHeadersMatch(Message m, MessageHeader h) {
+		assertEquals(m.getId(), h.getId());
+		if(m.getParent() == null) assertNull(h.getParent());
+		else assertEquals(m.getParent(), h.getParent());
+		if(m.getGroup() == null) assertNull(h.getGroup());
+		else assertEquals(m.getGroup(), h.getGroup());
+		if(m.getAuthor() == null) assertNull(h.getAuthor());
+		else assertEquals(m.getAuthor(), h.getAuthor());
+		assertEquals(m.getSubject(), h.getSubject());
+		assertEquals(m.getTimestamp(), h.getTimestamp());
+	}
+
+	@Test
+	public void testReadFlag() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Subscribe to a group and store a message
+		db.addSubscription(txn, group);
+		db.addGroupMessage(txn, message);
+
+		// The message should be unread by default
+		assertFalse(db.getRead(txn, messageId));
+		// Marking the message read should return the old value
+		assertFalse(db.setRead(txn, messageId, true));
+		assertTrue(db.setRead(txn, messageId, true));
+		// The message should be read
+		assertTrue(db.getRead(txn, messageId));
+		// Marking the message unread should return the old value
+		assertTrue(db.setRead(txn, messageId, false));
+		assertFalse(db.setRead(txn, messageId, false));
+		// Unsubscribe from the group
+		db.removeSubscription(txn, groupId);
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testStarredFlag() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Subscribe to a group and store a message
+		db.addSubscription(txn, group);
+		db.addGroupMessage(txn, message);
+
+		// The message should be unstarred by default
+		assertFalse(db.getStarred(txn, messageId));
+		// Starring the message should return the old value
+		assertFalse(db.setStarred(txn, messageId, true));
+		assertTrue(db.setStarred(txn, messageId, true));
+		// The message should be starred
+		assertTrue(db.getStarred(txn, messageId));
+		// Unstarring the message should return the old value
+		assertTrue(db.setStarred(txn, messageId, false));
+		assertFalse(db.setStarred(txn, messageId, false));
+		// Unsubscribe from the group
+		db.removeSubscription(txn, groupId);
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testGetUnreadMessageCounts() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Subscribe to a couple of groups
+		db.addSubscription(txn, group);
+		GroupId groupId1 = new GroupId(TestUtils.getRandomId());
+		Group group1 = groupFactory.createGroup(groupId1, "Another group",
+				null);
+		db.addSubscription(txn, group1);
+
+		// Store two messages in the first group
+		db.addGroupMessage(txn, message);
+		MessageId messageId1 = new MessageId(TestUtils.getRandomId());
+		Message message1 = new TestMessage(messageId1, null, groupId,
+				authorId, subject, timestamp, raw);
+		db.addGroupMessage(txn, message1);
+
+		// Store one message in the second group
+		MessageId messageId2 = new MessageId(TestUtils.getRandomId());
+		Message message2 = new TestMessage(messageId2, null, groupId1,
+				authorId, subject, timestamp, raw);
+		db.addGroupMessage(txn, message2);
+
+		// Mark one of the messages in the first group read
+		assertFalse(db.setRead(txn, messageId, true));
+
+		// There should be one unread message in each group
+		Map<GroupId, Integer> counts = db.getUnreadMessageCounts(txn);
+		assertEquals(2, counts.size());
+		Integer count = counts.get(groupId);
+		assertNotNull(count);
+		assertEquals(1, count.intValue());
+		count = counts.get(groupId1);
+		assertNotNull(count);
+		assertEquals(1, count.intValue());
+
+		// Mark the read message unread (it will now be false rather than null)
+		assertTrue(db.setRead(txn, messageId, false));
+
+		// Mark the message in the second group read
+		assertFalse(db.setRead(txn, messageId2, true));
+
+		// There should be two unread messages in the first group, none in
+		// the second group
+		counts = db.getUnreadMessageCounts(txn);
+		assertEquals(1, counts.size());
+		count = counts.get(groupId);
+		assertNotNull(count);
+		assertEquals(2, count.intValue());
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testMultipleSubscriptionsAndUnsubscriptions() throws Exception {
+		// Create some groups
+		List<Group> groups = new ArrayList<Group>();
+		for(int i = 0; i < 100; i++) {
+			GroupId id = new GroupId(TestUtils.getRandomId());
+			groups.add(groupFactory.createGroup(id, "Group name", null));
+		}
+
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Subscribe to the groups and add a contact
+		for(Group g : groups) db.addSubscription(txn, g);
+		assertEquals(contactId, db.addContact(txn));
+
+		// Make the groups visible to the contact
+		Collections.shuffle(groups);
+		for(Group g : groups) db.addVisibility(txn, contactId, g.getId());
+
+		// Make some of the groups invisible to the contact and remove them all
+		Collections.shuffle(groups);
+		for(Group g : groups) {
+			if(Math.random() < 0.5)
+				db.removeVisibility(txn, contactId, g.getId());
+			db.removeSubscription(txn, g.getId());
+		}
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testTemporarySecrets() throws Exception {
+		// Create a contact transport and three consecutive temporary secrets
+		long epoch = 123L, clockDiff = 234L, latency = 345L;
+		boolean alice = false;
+		long outgoing1 = 456L, centre1 = 567L;
+		long outgoing2 = 678L, centre2 = 789L;
+		long outgoing3 = 890L, centre3 = 901L;
+		ContactTransport ct = new ContactTransport(contactId, transportId,
+				epoch, clockDiff, latency, alice);
+		Random random = new Random();
+		byte[] secret1 = new byte[32], bitmap1 = new byte[4];
+		random.nextBytes(secret1);
+		random.nextBytes(bitmap1);
+		TemporarySecret s1 = new TemporarySecret(contactId, transportId, epoch,
+				clockDiff, latency, alice, 0L, secret1, outgoing1, centre1,
+				bitmap1);
+		byte[] secret2 = new byte[32], bitmap2 = new byte[4];
+		random.nextBytes(secret2);
+		random.nextBytes(bitmap2);
+		TemporarySecret s2 = new TemporarySecret(contactId, transportId, epoch,
+				clockDiff, latency, alice, 1L, secret2, outgoing2, centre2,
+				bitmap2);
+		byte[] secret3 = new byte[32], bitmap3 = new byte[4];
+		random.nextBytes(secret3);
+		random.nextBytes(bitmap3);
+		TemporarySecret s3 = new TemporarySecret(contactId, transportId, epoch,
+				clockDiff, latency, alice, 2L, secret3, outgoing3, centre3,
+				bitmap3);
+
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Initially there should be no secrets in the database
+		assertEquals(Collections.emptyList(), db.getSecrets(txn));
+
+		// Add the contact transport and the first two secrets
+		assertEquals(contactId, db.addContact(txn));
+		db.addContactTransport(txn, ct);
+		db.addSecrets(txn, Arrays.asList(s1, s2));
+
+		// Retrieve the first two secrets
+		Collection<TemporarySecret> secrets = db.getSecrets(txn);
+		assertEquals(2, secrets.size());
+		boolean foundFirst = false, foundSecond = false;
+		for(TemporarySecret s : secrets) {
+			assertEquals(contactId, s.getContactId());
+			assertEquals(transportId, s.getTransportId());
+			assertEquals(epoch, s.getEpoch());
+			assertEquals(clockDiff, s.getClockDifference());
+			assertEquals(latency, s.getLatency());
+			assertEquals(alice, s.getAlice());
+			if(s.getPeriod() == 0L) {
+				assertArrayEquals(secret1, s.getSecret());
+				assertEquals(outgoing1, s.getOutgoingConnectionCounter());
+				assertEquals(centre1, s.getWindowCentre());
+				assertArrayEquals(bitmap1, s.getWindowBitmap());
+				foundFirst = true;
+			} else if(s.getPeriod() == 1L) {
+				assertArrayEquals(secret2, s.getSecret());
+				assertEquals(outgoing2, s.getOutgoingConnectionCounter());
+				assertEquals(centre2, s.getWindowCentre());
+				assertArrayEquals(bitmap2, s.getWindowBitmap());
+				foundSecond = true;
+			} else {
+				fail();
+			}
+		}
+		assertTrue(foundFirst);
+		assertTrue(foundSecond);
+
+		// Adding the third secret (period 2) should delete the first (period 0)
+		db.addSecrets(txn, Arrays.asList(s3));
+		secrets = db.getSecrets(txn);
+		assertEquals(2, secrets.size());
+		foundSecond = false;
+		boolean foundThird = false;
+		for(TemporarySecret s : secrets) {
+			assertEquals(contactId, s.getContactId());
+			assertEquals(transportId, s.getTransportId());
+			assertEquals(epoch, s.getEpoch());
+			assertEquals(clockDiff, s.getClockDifference());
+			assertEquals(latency, s.getLatency());
+			assertEquals(alice, s.getAlice());
+			if(s.getPeriod() == 1L) {
+				assertArrayEquals(secret2, s.getSecret());
+				assertEquals(outgoing2, s.getOutgoingConnectionCounter());
+				assertEquals(centre2, s.getWindowCentre());
+				assertArrayEquals(bitmap2, s.getWindowBitmap());
+				foundSecond = true;
+			} else if(s.getPeriod() == 2L) {
+				assertArrayEquals(secret3, s.getSecret());
+				assertEquals(outgoing3, s.getOutgoingConnectionCounter());
+				assertEquals(centre3, s.getWindowCentre());
+				assertArrayEquals(bitmap3, s.getWindowBitmap());
+				foundThird = true;
+			} else {
+				fail();
+			}
+		}
+		assertTrue(foundSecond);
+		assertTrue(foundThird);
+
+		// Removing the contact should remove the secrets
+		db.removeContact(txn, contactId);
+		assertEquals(Collections.emptyList(), db.getSecrets(txn));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testIncrementConnectionCounter() throws Exception {
+		// Create a contact transport and a temporary secret
+		long epoch = 123L, clockDiff = 234L, latency = 345L;
+		boolean alice = false;
+		long period = 456L, outgoing = 567L, centre = 678L;
+		ContactTransport ct = new ContactTransport(contactId, transportId,
+				epoch, clockDiff, latency, alice);
+		Random random = new Random();
+		byte[] secret = new byte[32], bitmap = new byte[4];
+		random.nextBytes(secret);
+		TemporarySecret s = new TemporarySecret(contactId, transportId, epoch,
+				clockDiff, latency, alice, period, secret, outgoing, centre,
+				bitmap);
+
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add the contact transport and the temporary secret
+		assertEquals(contactId, db.addContact(txn));
+		db.addContactTransport(txn, ct);
+		db.addSecrets(txn, Arrays.asList(s));
+
+		// Retrieve the secret
+		Collection<TemporarySecret> secrets = db.getSecrets(txn);
+		assertEquals(1, secrets.size());
+		s = secrets.iterator().next();
+		assertEquals(contactId, s.getContactId());
+		assertEquals(transportId, s.getTransportId());
+		assertEquals(period, s.getPeriod());
+		assertArrayEquals(secret, s.getSecret());
+		assertEquals(outgoing, s.getOutgoingConnectionCounter());
+		assertEquals(centre, s.getWindowCentre());
+		assertArrayEquals(bitmap, s.getWindowBitmap());
+
+		// Increment the connection counter twice and retrieve the secret again
+		assertEquals(outgoing, db.incrementConnectionCounter(txn,
+				s.getContactId(), s.getTransportId(), s.getPeriod()));
+		assertEquals(outgoing + 1L, db.incrementConnectionCounter(txn,
+				s.getContactId(), s.getTransportId(), s.getPeriod()));
+		secrets = db.getSecrets(txn);
+		assertEquals(1, secrets.size());
+		s = secrets.iterator().next();
+		assertEquals(contactId, s.getContactId());
+		assertEquals(transportId, s.getTransportId());
+		assertEquals(period, s.getPeriod());
+		assertArrayEquals(secret, s.getSecret());
+		assertEquals(outgoing + 2L, s.getOutgoingConnectionCounter());
+		assertEquals(centre, s.getWindowCentre());
+		assertArrayEquals(bitmap, s.getWindowBitmap());
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testSetConnectionWindow() throws Exception {
+		// Create a contact transport and a temporary secret
+		long epoch = 123L, clockDiff = 234L, latency = 345L;
+		boolean alice = false;
+		long period = 456L, outgoing = 567L, centre = 678L;
+		ContactTransport ct = new ContactTransport(contactId, transportId,
+				epoch, clockDiff, latency, alice);
+		Random random = new Random();
+		byte[] secret = new byte[32], bitmap = new byte[4];
+		random.nextBytes(secret);
+		TemporarySecret s = new TemporarySecret(contactId, transportId, epoch,
+				clockDiff, latency, alice, period, secret, outgoing, centre,
+				bitmap);
+
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Add the contact transport and the temporary secret
+		assertEquals(contactId, db.addContact(txn));
+		db.addContactTransport(txn, ct);
+		db.addSecrets(txn, Arrays.asList(s));
+
+		// Retrieve the secret
+		Collection<TemporarySecret> secrets = db.getSecrets(txn);
+		assertEquals(1, secrets.size());
+		s = secrets.iterator().next();
+		assertEquals(contactId, s.getContactId());
+		assertEquals(transportId, s.getTransportId());
+		assertEquals(period, s.getPeriod());
+		assertArrayEquals(secret, s.getSecret());
+		assertEquals(outgoing, s.getOutgoingConnectionCounter());
+		assertEquals(centre, s.getWindowCentre());
+		assertArrayEquals(bitmap, s.getWindowBitmap());
+
+		// Update the connection window and retrieve the secret again
+		random.nextBytes(bitmap);
+		db.setConnectionWindow(txn, contactId, transportId, period, centre,
+				bitmap);
+		secrets = db.getSecrets(txn);
+		assertEquals(1, secrets.size());
+		s = secrets.iterator().next();
+		assertEquals(contactId, s.getContactId());
+		assertEquals(transportId, s.getTransportId());
+		assertEquals(period, s.getPeriod());
+		assertArrayEquals(secret, s.getSecret());
+		assertEquals(outgoing, s.getOutgoingConnectionCounter());
+		assertEquals(centre, s.getWindowCentre());
+		assertArrayEquals(bitmap, s.getWindowBitmap());
+
+		// Updating a nonexistent window should not throw an exception
+		db.setConnectionWindow(txn, contactId, transportId, period + 1L, 1L,
+				bitmap);
+		// The nonexistent window should not have been created
+		secrets = db.getSecrets(txn);
+		assertEquals(1, secrets.size());
+		s = secrets.iterator().next();
+		assertEquals(contactId, s.getContactId());
+		assertEquals(transportId, s.getTransportId());
+		assertEquals(period, s.getPeriod());
+		assertArrayEquals(secret, s.getSecret());
+		assertEquals(outgoing, s.getOutgoingConnectionCounter());
+		assertEquals(centre, s.getWindowCentre());
+		assertArrayEquals(bitmap, s.getWindowBitmap());
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testContactTransports() throws Exception {
+		// Create some contact transports
+		long epoch1 = 123L, clockDiff1 = 234L, latency1 = 345L;
+		long epoch2 = 456L, clockDiff2 = 567L, latency2 = 678L;
+		boolean alice1 = true, alice2 = false;
+		TransportId transportId1 = new TransportId(TestUtils.getRandomId());
+		TransportId transportId2 = new TransportId(TestUtils.getRandomId());
+		ContactTransport ct1 = new ContactTransport(contactId, transportId1,
+				epoch1, clockDiff1, latency1, alice1);
+		ContactTransport ct2 = new ContactTransport(contactId, transportId2,
+				epoch2, clockDiff2, latency2, alice2);
+
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+
+		// Initially there should be no contact transports in the database
+		assertEquals(Collections.emptyList(), db.getContactTransports(txn));
+
+		// Add a contact and the contact transports
+		assertEquals(contactId, db.addContact(txn));
+		db.addContactTransport(txn, ct1);
+		db.addContactTransport(txn, ct2);
+
+		// Retrieve the contact transports
+		Collection<ContactTransport> cts = db.getContactTransports(txn);
+		assertEquals(2, cts.size());
+		boolean foundFirst = false, foundSecond = false;
+		for(ContactTransport ct : cts) {
+			assertEquals(contactId, ct.getContactId());
+			if(ct.getTransportId().equals(transportId1)) {
+				assertEquals(epoch1, ct.getEpoch());
+				assertEquals(clockDiff1, ct.getClockDifference());
+				assertEquals(latency1, ct.getLatency());
+				assertEquals(alice1, ct.getAlice());
+				foundFirst = true;
+			} else if(ct.getTransportId().equals(transportId2)) {
+				assertEquals(epoch2, ct.getEpoch());
+				assertEquals(clockDiff2, ct.getClockDifference());
+				assertEquals(latency2, ct.getLatency());
+				assertEquals(alice2, ct.getAlice());
+				foundSecond = true;
+			} else {
+				fail();
+			}
+		}
+		assertTrue(foundFirst);
+		assertTrue(foundSecond);
+
+		// Removing the contact should remove the contact transports
+		db.removeContact(txn, contactId);
+		assertEquals(Collections.emptyList(), db.getContactTransports(txn));
+
+		db.commitTransaction(txn);
+		db.close();
+	}
+
+	@Test
+	public void testExceptionHandling() throws Exception {
+		Database<Connection> db = open(false);
+		Connection txn = db.startTransaction();
+		try {
+			// Ask for a nonexistent message - an exception should be thrown
+			db.getMessage(txn, messageId);
+			fail();
+		} catch(DbException expected) {
+			// It should be possible to abort the transaction without error
+			db.abortTransaction(txn);
+		}
+		// It should be possible to close the database cleanly
+		db.close();
+	}
+
+	private Database<Connection> open(boolean resume) throws Exception {
+		Database<Connection> db = new H2Database(
+				new TestDatabaseConfig(testDir, MAX_SIZE), groupFactory,
+				new SystemClock());
+		db.open(resume);
+		return db;
+	}
+
+	@After
+	public void tearDown() {
+		TestUtils.deleteTestDirectory(testDir);
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/db/TestGroup.java b/briar-tests/src/net/sf/briar/db/TestGroup.java
new file mode 100644
index 0000000000000000000000000000000000000000..4e78e0788036179499b1ce2472ae1c8af8a7679e
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/db/TestGroup.java
@@ -0,0 +1,29 @@
+package net.sf.briar.db;
+
+import net.sf.briar.api.protocol.Group;
+import net.sf.briar.api.protocol.GroupId;
+
+class TestGroup implements Group {
+
+	private final GroupId id;
+	private final String name;
+	private final byte[] publicKey;
+
+	public TestGroup(GroupId id, String name, byte[] publicKey) {
+		this.id = id;
+		this.name = name;
+		this.publicKey = publicKey;
+	}
+
+	public GroupId getId() {
+		return id;
+	}
+
+	public String getName() {
+		return name;
+	}
+
+	public byte[] getPublicKey() {
+		return publicKey;
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/db/TestGroupFactory.java b/briar-tests/src/net/sf/briar/db/TestGroupFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..89f29b92d14c217bdfad67c5f60ee3d399971313
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/db/TestGroupFactory.java
@@ -0,0 +1,20 @@
+package net.sf.briar.db;
+
+import java.io.IOException;
+
+import net.sf.briar.TestUtils;
+import net.sf.briar.api.protocol.Group;
+import net.sf.briar.api.protocol.GroupFactory;
+import net.sf.briar.api.protocol.GroupId;
+
+class TestGroupFactory implements GroupFactory {
+
+	public Group createGroup(String name, byte[] publicKey) throws IOException {
+		GroupId id = new GroupId(TestUtils.getRandomId());
+		return new TestGroup(id, name, publicKey);
+	}
+
+	public Group createGroup(GroupId id, String name, byte[] publicKey) {
+		return new TestGroup(id, name, publicKey);
+	}
+}
\ No newline at end of file
diff --git a/briar-tests/src/net/sf/briar/db/TestMessage.java b/briar-tests/src/net/sf/briar/db/TestMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..4dbe7584a5b6296beafa3ccc47d8352ed6e869ea
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/db/TestMessage.java
@@ -0,0 +1,89 @@
+package net.sf.briar.db;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+
+import net.sf.briar.api.protocol.AuthorId;
+import net.sf.briar.api.protocol.GroupId;
+import net.sf.briar.api.protocol.Message;
+import net.sf.briar.api.protocol.MessageId;
+
+class TestMessage implements Message {
+
+	private final MessageId id, parent;
+	private final GroupId group;
+	private final AuthorId author;
+	private final String subject;
+	private final long timestamp;
+	private final byte[] raw;
+	private final int bodyStart, bodyLength;
+
+	public TestMessage(MessageId id, MessageId parent, GroupId group,
+			AuthorId author, String subject, long timestamp, byte[] raw) {
+		this(id, parent, group, author, subject, timestamp, raw, 0, raw.length);
+	}
+
+	public TestMessage(MessageId id, MessageId parent, GroupId group,
+			AuthorId author, String subject, long timestamp, byte[] raw,
+			int bodyStart, int bodyLength) {
+		this.id = id;
+		this.parent = parent;
+		this.group = group;
+		this.author = author;
+		this.subject = subject;
+		this.timestamp = timestamp;
+		this.raw = raw;
+		this.bodyStart = bodyStart;
+		this.bodyLength = bodyLength;
+	}
+
+	public MessageId getId() {
+		return id;
+	}
+
+	public MessageId getParent() {
+		return parent;
+	}
+
+	public GroupId getGroup() {
+		return group;
+	}
+
+	public AuthorId getAuthor() {
+		return author;
+	}
+
+	public String getSubject() {
+		return subject;
+	}
+
+	public long getTimestamp() {
+		return timestamp;
+	}
+
+	public byte[] getSerialised() {
+		return raw;
+	}
+
+	public int getBodyStart() {
+		return bodyStart;
+	}
+
+	public int getBodyLength() {
+		return bodyLength;
+	}
+
+	public InputStream getSerialisedStream() {
+		return new ByteArrayInputStream(raw);
+	}
+
+	@Override
+	public boolean equals(Object o) {
+		return o instanceof Message && id.equals(((Message)o).getId());
+	}
+
+	@Override
+	public int hashCode() {
+		return id.hashCode();
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/lifecycle/ShutdownManagerImplTest.java b/briar-tests/src/net/sf/briar/lifecycle/ShutdownManagerImplTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..da38215693134803e6a1dd0e008f65a91756f938
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/lifecycle/ShutdownManagerImplTest.java
@@ -0,0 +1,33 @@
+package net.sf.briar.lifecycle;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.api.lifecycle.ShutdownManager;
+
+import org.junit.Test;
+
+public class ShutdownManagerImplTest extends BriarTestCase {
+
+	@Test
+	public void testAddAndRemove() {
+		ShutdownManager s = createShutdownManager();
+		Set<Integer> handles = new HashSet<Integer>();
+		for(int i = 0; i < 100; i++) {
+			int handle = s.addShutdownHook(new Runnable() {
+				public void run() {}
+			});
+			// The handles should all be distinct
+			assertTrue(handles.add(handle));
+		}
+		// The hooks should be removable
+		for(int handle : handles) assertTrue(s.removeShutdownHook(handle));
+		// The hooks should no longer be removable
+		for(int handle : handles) assertFalse(s.removeShutdownHook(handle));
+	}
+
+	protected ShutdownManager createShutdownManager() {
+		return new ShutdownManagerImpl();
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/lifecycle/WindowsShutdownManagerImplTest.java b/briar-tests/src/net/sf/briar/lifecycle/WindowsShutdownManagerImplTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..ce872e97dec7983b0b8af3bd8ca1a076f5ac0c4b
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/lifecycle/WindowsShutdownManagerImplTest.java
@@ -0,0 +1,39 @@
+package net.sf.briar.lifecycle;
+
+import net.sf.briar.api.lifecycle.ShutdownManager;
+
+import org.junit.Test;
+
+public class WindowsShutdownManagerImplTest extends ShutdownManagerImplTest {
+
+	@Override
+	protected ShutdownManager createShutdownManager() {
+		return new WindowsShutdownManagerImpl();
+	}
+
+	@Test
+	public void testManagerWaitsForHooksToRun() {
+		WindowsShutdownManagerImpl s = new WindowsShutdownManagerImpl();
+		SlowHook[] hooks = new SlowHook[10];
+		for(int i = 0; i < hooks.length; i++) {
+			hooks[i] = new SlowHook();
+			s.addShutdownHook(hooks[i]);
+		}
+		s.runShutdownHooks();
+		for(int i = 0; i < hooks.length; i++) assertTrue(hooks[i].finished);
+	}
+
+	private static class SlowHook implements Runnable {
+
+		private volatile boolean finished = false;
+
+		public void run() {
+			try {
+				Thread.sleep(100);
+				finished = true;
+			} catch(InterruptedException e) {
+				fail();
+			}
+		}
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/plugins/DuplexClientTest.java b/briar-tests/src/net/sf/briar/plugins/DuplexClientTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..2ffdace5eb168170e8cd7d40c6cf330071dc523d
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/plugins/DuplexClientTest.java
@@ -0,0 +1,101 @@
+package net.sf.briar.plugins;
+
+import java.io.IOException;
+import java.util.Map;
+
+import net.sf.briar.api.ContactId;
+import net.sf.briar.api.TransportConfig;
+import net.sf.briar.api.TransportProperties;
+import net.sf.briar.api.plugins.duplex.DuplexPluginCallback;
+import net.sf.briar.api.plugins.duplex.DuplexTransportConnection;
+
+public abstract class DuplexClientTest extends DuplexTest {
+
+	protected ClientCallback callback = null;
+
+	protected void run() throws IOException {
+		assert plugin != null;
+		// Start the plugin
+		System.out.println("Starting plugin");
+		plugin.start();
+		// Try to connect to the server
+		System.out.println("Creating connection");
+		DuplexTransportConnection d = plugin.createConnection(contactId);
+		if(d == null) {
+			System.out.println("Connection failed");
+		} else {
+			System.out.println("Connection created");
+			receiveChallengeSendResponse(d);
+		}
+		// Try to send an invitation
+		System.out.println("Sending invitation");
+		d = plugin.sendInvitation(getPseudoRandom(123), INVITATION_TIMEOUT);
+		if(d == null) {
+			System.out.println("Connection failed");
+		} else {
+			System.out.println("Connection created");
+			receiveChallengeSendResponse(d);
+		}
+		// Try to accept an invitation
+		System.out.println("Accepting invitation");
+		d = plugin.acceptInvitation(getPseudoRandom(456), INVITATION_TIMEOUT);
+		if(d == null) {
+			System.out.println("Connection failed");
+		} else {
+			System.out.println("Connection created");
+			sendChallengeReceiveResponse(d);
+		}
+		// Stop the plugin
+		System.out.println("Stopping plugin");
+		plugin.stop();
+	}
+
+	protected static class ClientCallback implements DuplexPluginCallback {
+
+		private TransportConfig config = null;
+		private TransportProperties local = null;
+		private Map<ContactId, TransportProperties> remote = null;
+
+		public ClientCallback(TransportConfig config, TransportProperties local,
+				Map<ContactId, TransportProperties> remote) {
+			this.config = config;
+			this.local = local;
+			this.remote = remote;
+		}
+
+		public TransportConfig getConfig() {
+			return config;
+		}
+
+		public TransportProperties getLocalProperties() {
+			return local;
+		}
+
+		public Map<ContactId, TransportProperties> getRemoteProperties() {
+			return remote;
+		}
+
+		public void mergeConfig(TransportConfig c) {
+			config = c;
+		}
+
+		public void mergeLocalProperties(TransportProperties p) {
+			local = p;
+		}
+
+		public int showChoice(String[] options, String... message) {
+			return -1;
+		}
+
+		public boolean showConfirmationMessage(String... message) {
+			return false;
+		}
+
+		public void showMessage(String... message) {}
+
+		public void incomingConnectionCreated(DuplexTransportConnection d) {}
+
+		public void outgoingConnectionCreated(ContactId contactId,
+				DuplexTransportConnection d) {}
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/plugins/DuplexServerTest.java b/briar-tests/src/net/sf/briar/plugins/DuplexServerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..956eea2d33e09f207d9a57db16ad082a42f0db8a
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/plugins/DuplexServerTest.java
@@ -0,0 +1,103 @@
+package net.sf.briar.plugins;
+
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+
+import net.sf.briar.api.ContactId;
+import net.sf.briar.api.TransportConfig;
+import net.sf.briar.api.TransportProperties;
+import net.sf.briar.api.plugins.duplex.DuplexPluginCallback;
+import net.sf.briar.api.plugins.duplex.DuplexTransportConnection;
+
+public abstract class DuplexServerTest extends DuplexTest {
+
+	protected ServerCallback callback = null;
+
+	protected void run() throws Exception {
+		assert callback != null;
+		assert plugin != null;
+		// Start the plugin
+		System.out.println("Starting plugin");
+		plugin.start();
+		// Wait for a connection
+		System.out.println("Waiting for connection");
+		callback.latch.await();
+		// Try to accept an invitation
+		System.out.println("Accepting invitation");
+		DuplexTransportConnection d = plugin.acceptInvitation(
+				getPseudoRandom(123), INVITATION_TIMEOUT);
+		if(d == null) {
+			System.out.println("Connection failed");
+		} else {
+			System.out.println("Connection created");
+			sendChallengeReceiveResponse(d);
+		}
+		// Try to send an invitation
+		System.out.println("Sending invitation");
+		d = plugin.sendInvitation(getPseudoRandom(456), INVITATION_TIMEOUT);
+		if(d == null) {
+			System.out.println("Connection failed");
+		} else {
+			System.out.println("Connection created");
+			receiveChallengeSendResponse(d);
+		}
+		// Stop the plugin
+		System.out.println("Stopping plugin");
+		plugin.stop();
+	}
+
+	protected class ServerCallback implements DuplexPluginCallback {
+
+		private final CountDownLatch latch = new CountDownLatch(1);
+
+		private TransportConfig config;
+		private TransportProperties local;
+		private Map<ContactId, TransportProperties> remote;
+
+		public ServerCallback(TransportConfig config, TransportProperties local,
+				Map<ContactId, TransportProperties> remote) {
+			this.config = config;
+			this.local = local;
+			this.remote = remote;
+		}
+
+		public TransportConfig getConfig() {
+			return config;
+		}
+
+		public TransportProperties getLocalProperties() {
+			return local;
+		}
+
+		public Map<ContactId, TransportProperties> getRemoteProperties() {
+			return remote;
+		}
+
+		public void mergeConfig(TransportConfig c) {
+			config = c;
+		}
+
+		public void mergeLocalProperties(TransportProperties p) {
+			local = p;
+		}
+
+		public int showChoice(String[] options, String... message) {
+			return -1;
+		}
+
+		public boolean showConfirmationMessage(String... message) {
+			return false;
+		}
+
+		public void showMessage(String... message) {}
+
+		public void incomingConnectionCreated(DuplexTransportConnection d) {
+			System.out.println("Connection received");
+			sendChallengeReceiveResponse(d);
+			latch.countDown();
+		}
+
+		public void outgoingConnectionCreated(ContactId c,
+				DuplexTransportConnection d) {}
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/plugins/DuplexTest.java b/briar-tests/src/net/sf/briar/plugins/DuplexTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..80eee278f0c05acd481197c8fde43e62c57e04de
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/plugins/DuplexTest.java
@@ -0,0 +1,98 @@
+package net.sf.briar.plugins;
+
+import java.io.IOException;
+import java.io.PrintStream;
+import java.util.Random;
+import java.util.Scanner;
+
+import net.sf.briar.api.ContactId;
+import net.sf.briar.api.crypto.PseudoRandom;
+import net.sf.briar.api.plugins.duplex.DuplexPlugin;
+import net.sf.briar.api.plugins.duplex.DuplexTransportConnection;
+
+abstract class DuplexTest {
+
+	protected static final String CHALLENGE = "Carrots!";
+	protected static final String RESPONSE = "Potatoes!";
+	protected static final long INVITATION_TIMEOUT = 30 * 1000;
+
+	protected final ContactId contactId = new ContactId(234);
+
+	protected DuplexPlugin plugin = null;
+
+	protected void sendChallengeReceiveResponse(DuplexTransportConnection d) {
+		assert plugin != null;
+		try {
+			PrintStream out = new PrintStream(d.getOutputStream());
+			out.println(CHALLENGE);
+			System.out.println("Sent challenge: " + CHALLENGE);
+			Scanner in = new Scanner(d.getInputStream());
+			if(in.hasNextLine()) {
+				String response = in.nextLine();
+				System.out.println("Received response: " + response);
+				if(RESPONSE.equals(response)) {
+					System.out.println("Correct response");
+				} else {
+					System.out.println("Incorrect response");
+				}
+			} else {
+				System.out.println("No response");
+			}
+			d.dispose(false, true);
+		} catch(IOException e) {
+			e.printStackTrace();
+			try {
+				d.dispose(true, true);
+			} catch(IOException e1) {
+				e1.printStackTrace();
+			}
+		}
+	}
+
+	protected void receiveChallengeSendResponse(DuplexTransportConnection d) {
+		assert plugin != null;
+		try {
+			Scanner in = new Scanner(d.getInputStream());
+			if(in.hasNextLine()) {
+				String challenge = in.nextLine();
+				System.out.println("Received challenge: " + challenge);
+				if(CHALLENGE.equals(challenge)) {
+					PrintStream out = new PrintStream(d.getOutputStream());
+					out.println(RESPONSE);
+					System.out.println("Sent response: " + RESPONSE);
+				} else {
+					System.out.println("Incorrect challenge");
+				}
+			} else {
+				System.out.println("No challenge");
+			}
+			d.dispose(false, true);
+		} catch(IOException e) {
+			e.printStackTrace();
+			try {
+				d.dispose(true, true);
+			} catch(IOException e1) {
+				e1.printStackTrace();
+			}
+		}
+	}
+
+	protected PseudoRandom getPseudoRandom(int seed) {
+		return new TestPseudoRandom(seed);
+	}
+
+	private static class TestPseudoRandom implements PseudoRandom {
+
+		private final Random r;
+
+		private TestPseudoRandom(int seed) {
+			r = new Random(seed);
+		}
+
+		public byte[] nextBytes(int bytes) {
+			byte[] b = new byte[bytes];
+			r.nextBytes(b);
+			return b;
+		}
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/plugins/ImmediateExecutor.java b/briar-tests/src/net/sf/briar/plugins/ImmediateExecutor.java
new file mode 100644
index 0000000000000000000000000000000000000000..fe21666e06641800e38fdc5673b8847818d716b9
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/plugins/ImmediateExecutor.java
@@ -0,0 +1,10 @@
+package net.sf.briar.plugins;
+
+import java.util.concurrent.Executor;
+
+public class ImmediateExecutor implements Executor {
+
+	public void execute(Runnable r) {
+		r.run();
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/plugins/PluginManagerImplTest.java b/briar-tests/src/net/sf/briar/plugins/PluginManagerImplTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..f7bad8c34eefa2a6b83e613f5fcd38b7ceab4923
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/plugins/PluginManagerImplTest.java
@@ -0,0 +1,63 @@
+package net.sf.briar.plugins;
+
+import java.util.Collection;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.api.TransportConfig;
+import net.sf.briar.api.TransportProperties;
+import net.sf.briar.api.android.AndroidExecutor;
+import net.sf.briar.api.db.DatabaseComponent;
+import net.sf.briar.api.lifecycle.ShutdownManager;
+import net.sf.briar.api.protocol.TransportId;
+import net.sf.briar.api.transport.ConnectionDispatcher;
+import net.sf.briar.api.ui.UiCallback;
+
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.junit.Test;
+
+public class PluginManagerImplTest extends BriarTestCase {
+
+	@SuppressWarnings("unchecked")
+	@Test
+	public void testStartAndStop() throws Exception {
+		Mockery context = new Mockery();
+		final AndroidExecutor androidExecutor =
+				context.mock(AndroidExecutor.class);
+		final ShutdownManager shutdownManager =
+				context.mock(ShutdownManager.class);
+		final DatabaseComponent db = context.mock(DatabaseComponent.class);
+		final Poller poller = context.mock(Poller.class);
+		final ConnectionDispatcher dispatcher =
+				context.mock(ConnectionDispatcher.class);
+		final UiCallback uiCallback = context.mock(UiCallback.class);
+		context.checking(new Expectations() {{
+			// Start
+			oneOf(poller).start(with(any(Collection.class)));
+			allowing(db).getConfig(with(any(TransportId.class)));
+			will(returnValue(new TransportConfig()));
+			allowing(db).getLocalProperties(with(any(TransportId.class)));
+			will(returnValue(new TransportProperties()));
+			allowing(db).getRemoteProperties(with(any(TransportId.class)));
+			will(returnValue(new TransportProperties()));
+			allowing(db).mergeLocalProperties(with(any(TransportId.class)),
+					with(any(TransportProperties.class)));
+			// Stop
+			oneOf(poller).stop();
+			oneOf(androidExecutor).shutdown();
+		}});
+		ExecutorService executor = Executors.newCachedThreadPool();
+		PluginManagerImpl p = new PluginManagerImpl(executor, androidExecutor,
+				shutdownManager, db, poller, dispatcher, uiCallback);
+		// We expect either 3 or 4 plugins to be started, depending on whether
+		// the test machine has a Bluetooth device
+		int started = p.start(null);
+		int stopped = p.stop();
+		assertEquals(started, stopped);
+		assertTrue(started >= 3);
+		assertTrue(started <= 4);
+		context.assertIsSatisfied();
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/plugins/bluetooth/BluetoothClientTest.java b/briar-tests/src/net/sf/briar/plugins/bluetooth/BluetoothClientTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..402b9509f1f98fc5a59283b3e1591ec0196406d0
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/plugins/bluetooth/BluetoothClientTest.java
@@ -0,0 +1,44 @@
+package net.sf.briar.plugins.bluetooth;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+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.clock.SystemClock;
+import net.sf.briar.plugins.DuplexClientTest;
+
+// This is not a JUnit test - it has to be run manually while the server test
+// is running on another machine
+public class BluetoothClientTest extends DuplexClientTest {
+
+	private BluetoothClientTest(Executor executor, String serverAddress) {
+		// Store the server's Bluetooth address and UUID
+		TransportProperties p = new TransportProperties();
+		p.put("address", serverAddress);
+		p.put("uuid", BluetoothTest.getUuid());
+		Map<ContactId, TransportProperties> remote =
+			Collections.singletonMap(contactId, p);
+		// Create the plugin
+		callback = new ClientCallback(new TransportConfig(),
+				new TransportProperties(), remote);
+		plugin = new BluetoothPlugin(executor, new SystemClock(), callback, 0L);
+	}
+
+	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);
+		}
+		ExecutorService executor = Executors.newCachedThreadPool();
+		try {
+			new BluetoothClientTest(executor, args[0]).run();
+		} finally {
+			executor.shutdown();
+		}
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/plugins/bluetooth/BluetoothServerTest.java b/briar-tests/src/net/sf/briar/plugins/bluetooth/BluetoothServerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..d59dda8f59af52b74d2a9d782bbb1b2c26926574
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/plugins/bluetooth/BluetoothServerTest.java
@@ -0,0 +1,35 @@
+package net.sf.briar.plugins.bluetooth;
+
+import java.util.Collections;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import net.sf.briar.api.TransportConfig;
+import net.sf.briar.api.TransportProperties;
+import net.sf.briar.api.clock.SystemClock;
+import net.sf.briar.plugins.DuplexServerTest;
+
+// This is not a JUnit test - it has to be run manually while the client test
+// is running on another machine
+public class BluetoothServerTest extends DuplexServerTest {
+
+	private BluetoothServerTest(Executor executor) {
+		// Store the UUID
+		TransportProperties local = new TransportProperties();
+		local.put("uuid", BluetoothTest.getUuid());
+		// Create the plugin
+		callback = new ServerCallback(new TransportConfig(), local,
+				Collections.singletonMap(contactId, new TransportProperties()));
+		plugin = new BluetoothPlugin(executor, new SystemClock(), callback, 0L);
+	}
+
+	public static void main(String[] args) throws Exception {
+		ExecutorService executor = Executors.newCachedThreadPool();
+		try {
+			new BluetoothServerTest(executor).run();
+		} finally {
+			executor.shutdown();
+		}
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/plugins/bluetooth/BluetoothTest.java b/briar-tests/src/net/sf/briar/plugins/bluetooth/BluetoothTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..17676bb08b254671e72d8fcb9d94d47db3d4ff95
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/plugins/bluetooth/BluetoothTest.java
@@ -0,0 +1,13 @@
+package net.sf.briar.plugins.bluetooth;
+
+import java.util.UUID;
+
+class BluetoothTest {
+
+	private static final String EMPTY_UUID =
+			UUID.nameUUIDFromBytes(new byte[0]).toString().replaceAll("-", "");
+
+	static String getUuid() {
+		return EMPTY_UUID;
+	}
+}
\ No newline at end of file
diff --git a/briar-tests/src/net/sf/briar/plugins/file/LinuxRemovableDriveFinderTest.java b/briar-tests/src/net/sf/briar/plugins/file/LinuxRemovableDriveFinderTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..6634adc6577557ce1f18b6e64abcd01a27554102
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/plugins/file/LinuxRemovableDriveFinderTest.java
@@ -0,0 +1,25 @@
+package net.sf.briar.plugins.file;
+
+import net.sf.briar.BriarTestCase;
+
+import org.junit.Test;
+
+public class LinuxRemovableDriveFinderTest extends BriarTestCase {
+
+	@Test
+	public void testParseMountPoint() {
+		LinuxRemovableDriveFinder f = new LinuxRemovableDriveFinder();
+		String line = "/dev/sda3 on / type ext3"
+			+ " (rw,errors=remount-ro,commit=0)";
+		assertEquals("/", f.parseMountPoint(line));
+		line = "gvfs-fuse-daemon on /home/alice/.gvfs"
+			+ " type fuse.gvfs-fuse-daemon (rw,nosuid,nodev,user=alice)";
+		assertEquals(null, f.parseMountPoint(line)); // Can't be parsed
+		line = "fusectl on /sys/fs/fuse/connections type fusectl (rw)";
+		assertEquals(null, f.parseMountPoint(line)); // Can't be parsed
+		line = "/dev/sdd1 on /media/HAZ SPACE(!) type vfat"
+			+ " (rw,nosuid,nodev,uhelper=udisks,uid=1000,gid=1000,"
+			+ "shortname=mixed,dmask=0077,utf8=1,showexec,flush)";
+		assertEquals("/media/HAZ SPACE(!)", f.parseMountPoint(line));
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/plugins/file/MacRemovableDriveFinderTest.java b/briar-tests/src/net/sf/briar/plugins/file/MacRemovableDriveFinderTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..5b0fae4701e049704cdde5aedc95c68a19c930a2
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/plugins/file/MacRemovableDriveFinderTest.java
@@ -0,0 +1,23 @@
+package net.sf.briar.plugins.file;
+
+import net.sf.briar.BriarTestCase;
+
+import org.junit.Test;
+
+public class MacRemovableDriveFinderTest extends BriarTestCase {
+
+	@Test
+	public void testParseMountPoint() {
+		MacRemovableDriveFinder f = new MacRemovableDriveFinder();
+		String line = "/dev/disk0s3 on / (local, journaled)";
+		assertEquals("/", f.parseMountPoint(line));
+		line = "devfs on /dev (local)";
+		assertEquals(null, f.parseMountPoint(line)); // Can't be parsed
+		line = "<volfs> on /.vol";
+		assertEquals(null, f.parseMountPoint(line)); // Can't be parsed
+		line = "automount -nsl [117] on /Network (automounted)";
+		assertEquals(null, f.parseMountPoint(line)); // Can't be parsed
+		line = "/dev/disk1s1 on /Volumes/HAZ SPACE(!) (local, nodev, nosuid)";
+		assertEquals("/Volumes/HAZ SPACE(!)", f.parseMountPoint(line));
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/plugins/file/PollingRemovableDriveMonitorTest.java b/briar-tests/src/net/sf/briar/plugins/file/PollingRemovableDriveMonitorTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..f4968b8e1c2f0a8296edf952850e0e4d9af7ca96
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/plugins/file/PollingRemovableDriveMonitorTest.java
@@ -0,0 +1,95 @@
+package net.sf.briar.plugins.file;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.plugins.file.RemovableDriveMonitor.Callback;
+
+import org.junit.Test;
+
+public class PollingRemovableDriveMonitorTest extends BriarTestCase {
+
+	@Test
+	public void testOneCallbackPerFile() throws Exception {
+		// Create a finder that returns no files the first time, then two files
+		final File file1 = new File("foo");
+		final File file2 = new File("bar");
+		final RemovableDriveFinder finder = new RemovableDriveFinder() {
+
+			private AtomicBoolean firstCall = new AtomicBoolean(true);
+
+			public Collection<File> findRemovableDrives() throws IOException {
+				if(firstCall.getAndSet(false)) return Collections.emptyList();
+				else return Arrays.asList(file1, file2);
+			}
+		};
+		// Create a callback that waits for two files
+		final CountDownLatch latch = new CountDownLatch(2);
+		final List<File> detected = new ArrayList<File>();
+		Callback callback = new Callback() {
+
+			public void driveInserted(File f) {
+				detected.add(f);
+				latch.countDown();
+			}
+
+			public void exceptionThrown(IOException e) {
+				fail();
+			}
+		};
+		// Create the monitor and start it
+		final RemovableDriveMonitor monitor = new PollingRemovableDriveMonitor(
+				Executors.newCachedThreadPool(), finder, 1);
+		monitor.start(callback);
+		// Wait for the monitor to detect the files
+		assertTrue(latch.await(10, SECONDS));
+		monitor.stop();
+		// Check that both files were detected
+		assertEquals(2, detected.size());
+		assertTrue(detected.contains(file1));
+		assertTrue(detected.contains(file2));
+	}
+
+	@Test
+	public void testExceptionCallback() throws Exception {
+		// Create a finder that throws an exception the second time it's polled
+		final RemovableDriveFinder finder = new RemovableDriveFinder() {
+
+			private AtomicBoolean firstCall = new AtomicBoolean(true);
+
+			public Collection<File> findRemovableDrives() throws IOException {
+				if(firstCall.getAndSet(false)) return Collections.emptyList();
+				else throw new IOException();
+			}
+		};
+		// Create a callback that waits for an exception
+		final CountDownLatch latch = new CountDownLatch(1);
+		Callback callback = new Callback() {
+
+			public void driveInserted(File root) {
+				fail();
+			}
+
+			public void exceptionThrown(IOException e) {
+				latch.countDown();
+			}
+		};
+		// Create the monitor and start it
+		final RemovableDriveMonitor monitor = new PollingRemovableDriveMonitor(
+				Executors.newCachedThreadPool(), finder, 1);
+		monitor.start(callback);
+		assertTrue(latch.await(10, SECONDS));
+		monitor.stop();
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/plugins/file/RemovableDrivePluginTest.java b/briar-tests/src/net/sf/briar/plugins/file/RemovableDrivePluginTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..f7cbe893becdc5b754983549bc4bee7595bbb0d4
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/plugins/file/RemovableDrivePluginTest.java
@@ -0,0 +1,355 @@
+package net.sf.briar.plugins.file;
+
+import static net.sf.briar.api.transport.TransportConstants.MIN_CONNECTION_LENGTH;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.Executor;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.TestUtils;
+import net.sf.briar.api.ContactId;
+import net.sf.briar.api.plugins.simplex.SimplexPluginCallback;
+import net.sf.briar.api.plugins.simplex.SimplexTransportWriter;
+import net.sf.briar.plugins.ImmediateExecutor;
+import net.sf.briar.plugins.file.RemovableDriveMonitor.Callback;
+
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class RemovableDrivePluginTest extends BriarTestCase {
+
+	private final File testDir = TestUtils.getTestDirectory();
+	private final ContactId contactId = new ContactId(234);
+
+	@Before
+	public void setUp() {
+		testDir.mkdirs();
+	}
+
+	@Test
+	public void testWriterIsNullIfNoDrivesAreFound() throws Exception {
+		final List<File> drives = Collections.emptyList();
+
+		Mockery context = new Mockery();
+		final Executor executor = context.mock(Executor.class);
+		final SimplexPluginCallback callback =
+			context.mock(SimplexPluginCallback.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)));
+			oneOf(finder).findRemovableDrives();
+			will(returnValue(drives));
+		}});
+
+		RemovableDrivePlugin plugin = new RemovableDrivePlugin(executor,
+				callback, finder, monitor);
+		plugin.start();
+
+		assertNull(plugin.createWriter(contactId));
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testWriterIsNullIfNoDriveIsChosen() throws Exception {
+		final File drive1 = new File(testDir, "1");
+		final File drive2 = new File(testDir, "2");
+		final List<File> drives = new ArrayList<File>();
+		drives.add(drive1);
+		drives.add(drive2);
+
+		Mockery context = new Mockery();
+		final Executor executor = context.mock(Executor.class);
+		final SimplexPluginCallback callback =
+			context.mock(SimplexPluginCallback.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)));
+			oneOf(finder).findRemovableDrives();
+			will(returnValue(drives));
+			oneOf(callback).showChoice(with(any(String[].class)),
+					with(any(String.class)));
+			will(returnValue(-1)); // The user cancelled the choice
+		}});
+
+		RemovableDrivePlugin plugin = new RemovableDrivePlugin(executor,
+				callback, finder, monitor);
+		plugin.start();
+
+		assertNull(plugin.createWriter(contactId));
+		File[] files = drive1.listFiles();
+		assertTrue(files == null || files.length == 0);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testWriterIsNullIfOutputDirDoesNotExist() throws Exception {
+		final File drive1 = new File(testDir, "1");
+		final File drive2 = new File(testDir, "2");
+		final List<File> drives = new ArrayList<File>();
+		drives.add(drive1);
+		drives.add(drive2);
+
+		Mockery context = new Mockery();
+		final Executor executor = context.mock(Executor.class);
+		final SimplexPluginCallback callback =
+			context.mock(SimplexPluginCallback.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)));
+			oneOf(finder).findRemovableDrives();
+			will(returnValue(drives));
+			oneOf(callback).showChoice(with(any(String[].class)),
+					with(any(String.class)));
+			will(returnValue(0)); // The user chose drive1 but it doesn't exist
+		}});
+
+		RemovableDrivePlugin plugin = new RemovableDrivePlugin(executor,
+				callback, finder, monitor);
+		plugin.start();
+
+		assertNull(plugin.createWriter(contactId));
+		File[] files = drive1.listFiles();
+		assertTrue(files == null || files.length == 0);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testWriterIsNullIfOutputDirIsAFile() throws Exception {
+		final File drive1 = new File(testDir, "1");
+		final File drive2 = new File(testDir, "2");
+		final List<File> drives = new ArrayList<File>();
+		drives.add(drive1);
+		drives.add(drive2);
+		// Create drive1 as a file rather than a directory
+		assertTrue(drive1.createNewFile());
+
+		Mockery context = new Mockery();
+		final Executor executor = context.mock(Executor.class);
+		final SimplexPluginCallback callback =
+			context.mock(SimplexPluginCallback.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)));
+			oneOf(finder).findRemovableDrives();
+			will(returnValue(drives));
+			oneOf(callback).showChoice(with(any(String[].class)),
+					with(any(String.class)));
+			will(returnValue(0)); // The user chose drive1 but it's not a dir
+		}});
+
+		RemovableDrivePlugin plugin = new RemovableDrivePlugin(executor,
+				callback, finder, monitor);
+		plugin.start();
+
+		assertNull(plugin.createWriter(contactId));
+		File[] files = drive1.listFiles();
+		assertTrue(files == null || files.length == 0);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testWriterIsNotNullIfOutputDirIsADir() throws Exception {
+		final File drive1 = new File(testDir, "1");
+		final File drive2 = new File(testDir, "2");
+		final List<File> drives = new ArrayList<File>();
+		drives.add(drive1);
+		drives.add(drive2);
+		// Create drive1 as a directory
+		assertTrue(drive1.mkdir());
+
+		Mockery context = new Mockery();
+		final Executor executor = context.mock(Executor.class);
+		final SimplexPluginCallback callback =
+			context.mock(SimplexPluginCallback.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)));
+			oneOf(finder).findRemovableDrives();
+			will(returnValue(drives));
+			oneOf(callback).showChoice(with(any(String[].class)),
+					with(any(String.class)));
+			will(returnValue(0)); // The user chose drive1
+		}});
+
+		RemovableDrivePlugin plugin = new RemovableDrivePlugin(executor,
+				callback, finder, monitor);
+		plugin.start();
+
+		assertNotNull(plugin.createWriter(contactId));
+		// The output file should exist and should be empty
+		File[] files = drive1.listFiles();
+		assertNotNull(files);
+		assertEquals(1, files.length);
+		assertEquals(0L, files[0].length());
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testWritingToWriter() throws Exception {
+		final File drive1 = new File(testDir, "1");
+		final File drive2 = new File(testDir, "2");
+		final List<File> drives = new ArrayList<File>();
+		drives.add(drive1);
+		drives.add(drive2);
+		// Create drive1 as a directory
+		assertTrue(drive1.mkdir());
+
+		Mockery context = new Mockery();
+		final Executor executor = context.mock(Executor.class);
+		final SimplexPluginCallback callback =
+			context.mock(SimplexPluginCallback.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)));
+			oneOf(finder).findRemovableDrives();
+			will(returnValue(drives));
+			oneOf(callback).showChoice(with(any(String[].class)),
+					with(any(String.class)));
+			will(returnValue(0)); // The user chose drive1
+			oneOf(callback).showMessage(with(any(String.class)));
+		}});
+
+		RemovableDrivePlugin plugin = new RemovableDrivePlugin(executor,
+				callback, finder, monitor);
+		plugin.start();
+
+		SimplexTransportWriter writer = plugin.createWriter(contactId);
+		assertNotNull(writer);
+		// The output file should exist and should be empty
+		File[] files = drive1.listFiles();
+		assertNotNull(files);
+		assertEquals(1, files.length);
+		assertEquals(0L, files[0].length());
+		// Writing to the output stream should increase the size of the file
+		OutputStream out = writer.getOutputStream();
+		out.write(new byte[123]);
+		out.flush();
+		out.close();
+		// Disposing of the writer should not delete the file
+		writer.dispose(false);
+		assertTrue(files[0].exists());
+		assertEquals(123L, files[0].length());
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testEmptyDriveIsIgnored() throws Exception {
+		Mockery context = new Mockery();
+		final Executor executor = context.mock(Executor.class);
+		final SimplexPluginCallback callback =
+			context.mock(SimplexPluginCallback.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(executor,
+				callback, finder, monitor);
+		plugin.start();
+
+		plugin.driveInserted(testDir);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testFilenames() {
+		Mockery context = new Mockery();
+		final Executor executor = context.mock(Executor.class);
+		final SimplexPluginCallback callback =
+			context.mock(SimplexPluginCallback.class);
+		final RemovableDriveFinder finder =
+			context.mock(RemovableDriveFinder.class);
+		final RemovableDriveMonitor monitor =
+			context.mock(RemovableDriveMonitor.class);
+
+		RemovableDrivePlugin plugin = new RemovableDrivePlugin(executor,
+				callback, finder, monitor);
+
+		assertFalse(plugin.isPossibleConnectionFilename("abcdefg.dat"));
+		assertFalse(plugin.isPossibleConnectionFilename("abcdefghi.dat"));
+		assertFalse(plugin.isPossibleConnectionFilename("abcdefgh_dat"));
+		assertFalse(plugin.isPossibleConnectionFilename("abcdefgh.rat"));
+		assertTrue(plugin.isPossibleConnectionFilename("abcdefgh.dat"));
+		assertTrue(plugin.isPossibleConnectionFilename("ABCDEFGH.DAT"));
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testReaderIsCreated() throws Exception {
+		Mockery context = new Mockery();
+		final SimplexPluginCallback callback =
+			context.mock(SimplexPluginCallback.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)));
+			oneOf(callback).readerCreated(with(any(FileTransportReader.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[MIN_CONNECTION_LENGTH]);
+		out.flush();
+		out.close();
+		assertEquals(MIN_CONNECTION_LENGTH, f.length());
+		plugin.driveInserted(testDir);
+
+		context.assertIsSatisfied();
+	}
+
+	@After
+	public void tearDown() {
+		TestUtils.deleteTestDirectory(testDir);
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/plugins/file/UnixRemovableDriveMonitorTest.java b/briar-tests/src/net/sf/briar/plugins/file/UnixRemovableDriveMonitorTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..17bdba120e11d81c695d6fe00d0bfa5e8c611bdb
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/plugins/file/UnixRemovableDriveMonitorTest.java
@@ -0,0 +1,100 @@
+package net.sf.briar.plugins.file;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.TestUtils;
+import net.sf.briar.plugins.file.RemovableDriveMonitor.Callback;
+import net.sf.briar.util.OsUtils;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class UnixRemovableDriveMonitorTest extends BriarTestCase {
+
+	private final File testDir = TestUtils.getTestDirectory();
+
+	@Before
+	public void setUp() {
+		testDir.mkdirs();
+	}
+
+	@Test
+	public void testNonexistentDir() throws Exception {
+		if(!(OsUtils.isLinux() || OsUtils.isMacLeopardOrNewer())) {
+			System.err.println("Warning: Skipping test");
+			return;
+		}
+		File doesNotExist = new File(testDir, "doesNotExist");
+		RemovableDriveMonitor monitor = createMonitor(doesNotExist);
+		monitor.start(new Callback() {
+
+			public void driveInserted(File root) {
+				fail();
+			}
+
+			public void exceptionThrown(IOException e) {
+				fail();
+			}
+		});
+		monitor.stop();
+	}
+
+	@Test
+	public void testOneCallbackPerFile() throws Exception {
+		if(!(OsUtils.isLinux() || OsUtils.isMacLeopardOrNewer())) {
+			System.err.println("Warning: Skipping test");
+			return;
+		}
+		// Create a callback that will wait for two files before stopping
+		final List<File> detected = new ArrayList<File>();
+		final CountDownLatch latch = new CountDownLatch(2);
+		final Callback callback = new Callback() {
+
+			public void driveInserted(File f) {
+				detected.add(f);
+				latch.countDown();
+			}
+
+			public void exceptionThrown(IOException e) {
+				fail();
+			}
+		};
+		// Create the monitor and start it
+		RemovableDriveMonitor monitor = createMonitor(testDir);
+		monitor.start(callback);
+		// Create two files in the test directory
+		File file1 = new File(testDir, "1");
+		File file2 = new File(testDir, "2");
+		assertTrue(file1.createNewFile());
+		assertTrue(file2.createNewFile());
+		// Wait for the monitor to detect the files
+		assertTrue(latch.await(5, SECONDS));
+		monitor.stop();
+		// Check that both files were detected
+		assertEquals(2, detected.size());
+		assertTrue(detected.contains(file1));
+		assertTrue(detected.contains(file2));
+	}
+
+	@After
+	public void tearDown() {
+		TestUtils.deleteTestDirectory(testDir);
+	}
+
+	private RemovableDriveMonitor createMonitor(final File dir) {
+		return new UnixRemovableDriveMonitor() {
+			@Override
+			protected String[] getPathsToWatch() {
+				return new String[] { dir.getPath() };
+			}
+		};
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/plugins/tcp/LanTcpClientTest.java b/briar-tests/src/net/sf/briar/plugins/tcp/LanTcpClientTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..da1f8641da00889e4781e76bcc9ac563f1e65a8a
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/plugins/tcp/LanTcpClientTest.java
@@ -0,0 +1,45 @@
+package net.sf.briar.plugins.tcp;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+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.plugins.DuplexClientTest;
+import net.sf.briar.plugins.tcp.LanTcpPlugin;
+
+// This is not a JUnit test - it has to be run manually while the server test
+// is running on another machine
+public class LanTcpClientTest extends DuplexClientTest {
+
+	private LanTcpClientTest(Executor executor, String serverAddress,
+			String serverPort) {
+		// Store the server's internal address and port
+		TransportProperties p = new TransportProperties();
+		p.put("internal", serverAddress);
+		p.put("port", serverPort);
+		Map<ContactId, TransportProperties> remote =
+			Collections.singletonMap(contactId, p);
+		// Create the plugin
+		callback = new ClientCallback(new TransportConfig(),
+				new TransportProperties(), remote);
+		plugin = new LanTcpPlugin(executor, callback, 0L);
+	}
+
+	public static void main(String[] args) throws Exception {
+		if(args.length != 2) {
+			System.err.println("Please specify the server's address and port");
+			System.exit(1);
+		}
+		ExecutorService executor = Executors.newCachedThreadPool();
+		try {
+			new LanTcpClientTest(executor, args[0], args[1]).run();
+		} finally {
+			executor.shutdown();
+		}
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/plugins/tcp/LanTcpPluginTest.java b/briar-tests/src/net/sf/briar/plugins/tcp/LanTcpPluginTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..7c2a1350fc0b3c552457fdbe0acad55510b4136b
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/plugins/tcp/LanTcpPluginTest.java
@@ -0,0 +1,142 @@
+package net.sf.briar.plugins.tcp;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.Hashtable;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.api.ContactId;
+import net.sf.briar.api.TransportConfig;
+import net.sf.briar.api.TransportProperties;
+import net.sf.briar.api.plugins.duplex.DuplexPlugin;
+import net.sf.briar.api.plugins.duplex.DuplexPluginCallback;
+import net.sf.briar.api.plugins.duplex.DuplexTransportConnection;
+import net.sf.briar.plugins.tcp.LanTcpPlugin;
+
+import org.junit.Test;
+
+public class LanTcpPluginTest extends BriarTestCase {
+
+	private final ContactId contactId = new ContactId(234);
+
+	@Test
+	public void testIncomingConnection() throws Exception {
+		Callback callback = new Callback();
+		callback.local.put("address", "127.0.0.1");
+		callback.local.put("port", "0");
+		Executor e = Executors.newCachedThreadPool();
+		DuplexPlugin plugin = new LanTcpPlugin(e, callback, 0L);
+		plugin.start();
+		// The plugin should have bound a socket and stored the port number
+		assertTrue(callback.propertiesLatch.await(5, SECONDS));
+		String host = callback.local.get("address");
+		assertNotNull(host);
+		assertEquals("127.0.0.1", host);
+		String portString = callback.local.get("port");
+		assertNotNull(portString);
+		int port = Integer.valueOf(portString);
+		assertTrue(port > 0 && port < 65536);
+		// The plugin should be listening on the port
+		InetSocketAddress addr = new InetSocketAddress(host, port);
+		Socket s = new Socket();
+		s.connect(addr, 100);
+		assertTrue(callback.connectionsLatch.await(5, SECONDS));
+		s.close();
+		// Stop the plugin
+		plugin.stop();
+	}
+
+	@Test
+	public void testOutgoingConnection() throws Exception {
+		Callback callback = new Callback();
+		Executor e = Executors.newCachedThreadPool();
+		DuplexPlugin plugin = new LanTcpPlugin(e, callback, 0L);
+		plugin.start();
+		// Listen on a local port
+		final ServerSocket ss = new ServerSocket();
+		ss.bind(new InetSocketAddress("127.0.0.1", 0), 10);
+		int port = ss.getLocalPort();
+		final CountDownLatch latch = new CountDownLatch(1);
+		final AtomicBoolean error = new AtomicBoolean(false);
+		new Thread() {
+			@Override
+			public void run() {
+				try {
+					ss.accept();
+					latch.countDown();
+				} catch(IOException e) {
+					error.set(true);
+				}
+			}
+		}.start();
+		// Tell the plugin about the port
+		TransportProperties p = new TransportProperties();
+		p.put("address", "127.0.0.1");
+		p.put("port", String.valueOf(port));
+		callback.remote.put(contactId, p);
+		// Connect to the port
+		DuplexTransportConnection d = plugin.createConnection(contactId);
+		assertNotNull(d);
+		// Check that the connection was accepted
+		assertTrue(latch.await(5, SECONDS));
+		assertFalse(error.get());
+		// Clean up
+		d.dispose(false, true);
+		ss.close();
+		plugin.stop();
+	}
+
+	private static class Callback implements DuplexPluginCallback {
+
+		private final Map<ContactId, TransportProperties> remote =
+				new Hashtable<ContactId, TransportProperties>();
+		private final CountDownLatch propertiesLatch = new CountDownLatch(1);
+		private final CountDownLatch connectionsLatch = new CountDownLatch(1);
+		private final TransportProperties local = new TransportProperties();
+
+		public TransportConfig getConfig() {
+			return new TransportConfig();
+		}
+
+		public TransportProperties getLocalProperties() {
+			return local;
+		}
+
+		public Map<ContactId, TransportProperties> getRemoteProperties() {
+			return remote;
+		}
+
+		public void mergeConfig(TransportConfig c) {}
+
+		public void mergeLocalProperties(TransportProperties p) {
+			local.putAll(p);
+			propertiesLatch.countDown();
+		}
+
+		public int showChoice(String[] options, String... message) {
+			return -1;
+		}
+
+		public boolean showConfirmationMessage(String... message) {
+			return false;
+		}
+
+		public void showMessage(String... message) {}
+
+		public void incomingConnectionCreated(DuplexTransportConnection d) {
+			connectionsLatch.countDown();
+		}
+
+		public void outgoingConnectionCreated(ContactId c,
+				DuplexTransportConnection d) {}
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/plugins/tcp/LanTcpServerTest.java b/briar-tests/src/net/sf/briar/plugins/tcp/LanTcpServerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..5efe68677d499178a1d93ba9ca13b6ed301271b8
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/plugins/tcp/LanTcpServerTest.java
@@ -0,0 +1,32 @@
+package net.sf.briar.plugins.tcp;
+
+import java.util.Collections;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import net.sf.briar.api.TransportConfig;
+import net.sf.briar.api.TransportProperties;
+import net.sf.briar.plugins.DuplexServerTest;
+import net.sf.briar.plugins.tcp.LanTcpPlugin;
+
+// This is not a JUnit test - it has to be run manually while the client test
+// is running on another machine
+public class LanTcpServerTest extends DuplexServerTest {
+
+	private LanTcpServerTest(Executor executor) {
+		callback = new ServerCallback(new TransportConfig(),
+				new TransportProperties(),
+				Collections.singletonMap(contactId, new TransportProperties()));
+		plugin = new LanTcpPlugin(executor, callback, 0L);
+	}
+
+	public static void main(String[] args) throws Exception {
+		ExecutorService executor = Executors.newCachedThreadPool();
+		try {
+			new LanTcpServerTest(executor).run();
+		} finally {
+			executor.shutdown();
+		}
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/plugins/tor/TorPluginTest.java b/briar-tests/src/net/sf/briar/plugins/tor/TorPluginTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..a876cb496c5c9ef15bf2975489b97fe538ba0a2c
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/plugins/tor/TorPluginTest.java
@@ -0,0 +1,175 @@
+package net.sf.briar.plugins.tor;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import java.io.PrintStream;
+import java.util.Hashtable;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.api.ContactId;
+import net.sf.briar.api.TransportConfig;
+import net.sf.briar.api.TransportProperties;
+import net.sf.briar.api.plugins.duplex.DuplexPluginCallback;
+import net.sf.briar.api.plugins.duplex.DuplexTransportConnection;
+
+import org.junit.Test;
+
+public class TorPluginTest extends BriarTestCase {
+
+	private final ContactId contactId = new ContactId(234);
+
+	@Test
+	public void testHiddenService() throws Exception {
+		System.err.println("======== testHiddenService ========");
+		Executor e = Executors.newCachedThreadPool();
+		TorPlugin serverPlugin = null, clientPlugin = null;
+		try {
+			// Create a plugin instance for the server
+			Callback serverCallback = new Callback();
+			serverPlugin = new TorPlugin(e, serverCallback, 0L);
+			System.out.println("Starting server plugin");
+			serverPlugin.start();
+			// The plugin should create a hidden service... eventually
+			assertTrue(serverCallback.latch.await(600, SECONDS));
+			System.out.println("Started server plugin");
+			String onion = serverCallback.local.get("onion");
+			assertNotNull(onion);
+			assertTrue(onion.endsWith(".onion"));
+			// Create another plugin instance for the client
+			Callback clientCallback = new Callback();
+			clientCallback.config.put("noHiddenService", "");
+			TransportProperties p = new TransportProperties();
+			p.put("onion", onion);
+			clientCallback.remote.put(contactId, p);
+			clientPlugin = new TorPlugin(e, clientCallback, 0L);
+			System.out.println("Starting client plugin");
+			clientPlugin.start();
+			// The plugin should start without creating a hidden service
+			assertTrue(clientCallback.latch.await(600, SECONDS));
+			System.out.println("Started client plugin");
+			// Connect to the server's hidden service
+			System.out.println("Connecting to hidden service");
+			DuplexTransportConnection clientEnd =
+					clientPlugin.createConnection(contactId);
+			assertNotNull(clientEnd);
+			DuplexTransportConnection serverEnd =
+					serverCallback.incomingConnection;
+			assertNotNull(serverEnd);
+			System.out.println("Connected to hidden service");
+			// Send some data through the Tor connection
+			PrintStream out = new PrintStream(clientEnd.getOutputStream());
+			out.println("Hello world");
+			out.flush();
+			Scanner in = new Scanner(serverEnd.getInputStream());
+			assertTrue(in.hasNextLine());
+			assertEquals("Hello world", in.nextLine());
+			serverEnd.dispose(false, false);
+			clientEnd.dispose(false, false);
+		} finally {
+			// Stop the plugins
+			System.out.println("Stopping plugins");
+			if(serverPlugin != null) serverPlugin.stop();
+			if(clientPlugin != null) clientPlugin.stop();
+			System.out.println("Stopped plugins");
+		}
+	}
+
+	@Test
+	public void testStoreAndRetrievePrivateKey() throws Exception {
+		System.err.println("======== testStoreAndRetrievePrivateKey ========");
+		Executor e = Executors.newCachedThreadPool();
+		TorPlugin plugin = null;
+		try {
+			// Start a plugin instance with no private key
+			Callback callback = new Callback();
+			plugin = new TorPlugin(e, callback, 0L);
+			System.out.println("Starting plugin without private key");
+			plugin.start();
+			// The plugin should create a hidden service... eventually
+			assertTrue(callback.latch.await(600, SECONDS));
+			System.out.println("Started plugin");
+			String onion = callback.local.get("onion");
+			assertNotNull(onion);
+			assertTrue(onion.endsWith(".onion"));
+			// Get the PEM-encoded private key
+			String privateKey = callback.config.get("privateKey");
+			assertNotNull(privateKey);
+			// Stop the plugin
+			System.out.println("Stopping plugin");
+			plugin.stop();
+			System.out.println("Stopped plugin");
+			// Start another instance, reusing the private key
+			callback = new Callback();
+			callback.config.put("privateKey", privateKey);
+			plugin = new TorPlugin(e, callback, 0L);
+			System.out.println("Starting plugin with private key");
+			plugin.start();
+			// The plugin should create a hidden service... eventually
+			assertTrue(callback.latch.await(600, SECONDS));
+			System.out.println("Started plugin");
+			// The onion URL should be the same
+			assertEquals(onion, callback.local.get("onion"));
+			// The private key should be the same
+			assertEquals(privateKey, callback.config.get("privateKey"));
+		} finally {
+			// Stop the plugin
+			System.out.println("Stopping plugin");
+			if(plugin != null) plugin.stop();
+			System.out.println("Stopped plugin");
+		}
+	}
+
+	private static class Callback implements DuplexPluginCallback {
+
+		private final Map<ContactId, TransportProperties> remote =
+				new Hashtable<ContactId, TransportProperties>();
+		private final CountDownLatch latch = new CountDownLatch(1);
+		private final TransportConfig config = new TransportConfig();
+		private final TransportProperties local = new TransportProperties();
+
+		private volatile DuplexTransportConnection incomingConnection = null;
+
+		public TransportConfig getConfig() {
+			return config;
+		}
+
+		public TransportProperties getLocalProperties() {
+			return local;
+		}
+
+		public Map<ContactId, TransportProperties> getRemoteProperties() {
+			return remote;
+		}
+
+		public void mergeConfig(TransportConfig c) {
+			config.putAll(c);
+		}
+
+		public void mergeLocalProperties(TransportProperties p) {
+			local.putAll(p);
+			latch.countDown();
+		}
+
+		public int showChoice(String[] options, String... message) {
+			return -1;
+		}
+
+		public boolean showConfirmationMessage(String... message) {
+			return false;
+		}
+
+		public void showMessage(String... message) {}
+
+		public void incomingConnectionCreated(DuplexTransportConnection d) {
+			incomingConnection = d;
+		}
+
+		public void outgoingConnectionCreated(ContactId c,
+				DuplexTransportConnection d) {}
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/protocol/AckReaderTest.java b/briar-tests/src/net/sf/briar/protocol/AckReaderTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..a40a1f297db378d80beac2db7677c570253e4925
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/protocol/AckReaderTest.java
@@ -0,0 +1,124 @@
+package net.sf.briar.protocol;
+
+import static net.sf.briar.api.protocol.ProtocolConstants.MAX_PACKET_LENGTH;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.util.Collection;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.TestUtils;
+import net.sf.briar.api.FormatException;
+import net.sf.briar.api.protocol.Ack;
+import net.sf.briar.api.protocol.PacketFactory;
+import net.sf.briar.api.protocol.Types;
+import net.sf.briar.api.serial.Reader;
+import net.sf.briar.api.serial.ReaderFactory;
+import net.sf.briar.api.serial.SerialComponent;
+import net.sf.briar.api.serial.Writer;
+import net.sf.briar.api.serial.WriterFactory;
+import net.sf.briar.serial.SerialModule;
+
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.junit.Test;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+
+public class AckReaderTest extends BriarTestCase {
+
+	// FIXME: This is an integration test, not a unit test
+
+	private final SerialComponent serial;
+	private final ReaderFactory readerFactory;
+	private final WriterFactory writerFactory;
+	private final Mockery context;
+
+	public AckReaderTest() throws Exception {
+		super();
+		Injector i = Guice.createInjector(new SerialModule());
+		serial = i.getInstance(SerialComponent.class);
+		readerFactory = i.getInstance(ReaderFactory.class);
+		writerFactory = i.getInstance(WriterFactory.class);
+		context = new Mockery();
+	}
+
+	@Test
+	public void testFormatExceptionIfAckIsTooLarge() throws Exception {
+		PacketFactory packetFactory = context.mock(PacketFactory.class);
+		AckReader ackReader = new AckReader(packetFactory);
+
+		byte[] b = createAck(true);
+		ByteArrayInputStream in = new ByteArrayInputStream(b);
+		Reader reader = readerFactory.createReader(in);
+		reader.addStructReader(Types.ACK, ackReader);
+
+		try {
+			reader.readStruct(Types.ACK, Ack.class);
+			fail();
+		} catch(FormatException expected) {}
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	public void testNoFormatExceptionIfAckIsMaximumSize() throws Exception {
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		AckReader ackReader = new AckReader(packetFactory);
+		final Ack ack = context.mock(Ack.class);
+		context.checking(new Expectations() {{
+			oneOf(packetFactory).createAck(with(any(Collection.class)));
+			will(returnValue(ack));
+		}});
+
+		byte[] b = createAck(false);
+		ByteArrayInputStream in = new ByteArrayInputStream(b);
+		Reader reader = readerFactory.createReader(in);
+		reader.addStructReader(Types.ACK, ackReader);
+
+		assertEquals(ack, reader.readStruct(Types.ACK, Ack.class));
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testEmptyAck() throws Exception {
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		AckReader ackReader = new AckReader(packetFactory);
+
+		byte[] b = createEmptyAck();
+		ByteArrayInputStream in = new ByteArrayInputStream(b);
+		Reader reader = readerFactory.createReader(in);
+		reader.addStructReader(Types.ACK, ackReader);
+
+		try {
+			reader.readStruct(Types.ACK, Ack.class);
+			fail();
+		} catch(FormatException expected) {}
+		context.assertIsSatisfied();
+	}
+
+	private byte[] createAck(boolean tooBig) throws Exception {
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		Writer w = writerFactory.createWriter(out);
+		w.writeStructId(Types.ACK);
+		w.writeListStart();
+		while(out.size() + serial.getSerialisedUniqueIdLength()
+				< MAX_PACKET_LENGTH) {
+			w.writeBytes(TestUtils.getRandomId());
+		}
+		if(tooBig) w.writeBytes(TestUtils.getRandomId());
+		w.writeListEnd();
+		assertEquals(tooBig, out.size() > MAX_PACKET_LENGTH);
+		return out.toByteArray();
+	}
+
+	private byte[] createEmptyAck() throws Exception {
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		Writer w = writerFactory.createWriter(out);
+		w.writeStructId(Types.ACK);
+		w.writeListStart();
+		w.writeListEnd();
+		return out.toByteArray();
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/protocol/BatchReaderTest.java b/briar-tests/src/net/sf/briar/protocol/BatchReaderTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..6323638b01297da9aeab597cc301e6bb54e24342
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/protocol/BatchReaderTest.java
@@ -0,0 +1,137 @@
+package net.sf.briar.protocol;
+
+import static net.sf.briar.api.protocol.ProtocolConstants.MAX_PACKET_LENGTH;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Collections;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.api.FormatException;
+import net.sf.briar.api.protocol.Types;
+import net.sf.briar.api.protocol.UnverifiedBatch;
+import net.sf.briar.api.serial.Reader;
+import net.sf.briar.api.serial.ReaderFactory;
+import net.sf.briar.api.serial.StructReader;
+import net.sf.briar.api.serial.Writer;
+import net.sf.briar.api.serial.WriterFactory;
+import net.sf.briar.serial.SerialModule;
+
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.junit.Test;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+
+public class BatchReaderTest extends BriarTestCase {
+
+	// FIXME: This is an integration test, not a unit test
+
+	private final ReaderFactory readerFactory;
+	private final WriterFactory writerFactory;
+	private final Mockery context;
+	private final UnverifiedMessage message;
+	private final StructReader<UnverifiedMessage> messageReader;
+
+	public BatchReaderTest() throws Exception {
+		super();
+		Injector i = Guice.createInjector(new SerialModule());
+		readerFactory = i.getInstance(ReaderFactory.class);
+		writerFactory = i.getInstance(WriterFactory.class);
+		context = new Mockery();
+		message = context.mock(UnverifiedMessage.class);
+		messageReader = new TestMessageReader();
+	}
+
+	@Test
+	public void testFormatExceptionIfBatchIsTooLarge() throws Exception {
+		UnverifiedBatchFactory batchFactory =
+			context.mock(UnverifiedBatchFactory.class);
+		BatchReader batchReader = new BatchReader(messageReader, batchFactory);
+
+		byte[] b = createBatch(MAX_PACKET_LENGTH + 1);
+		ByteArrayInputStream in = new ByteArrayInputStream(b);
+		Reader reader = readerFactory.createReader(in);
+		reader.addStructReader(Types.BATCH, batchReader);
+
+		try {
+			reader.readStruct(Types.BATCH, UnverifiedBatch.class);
+			fail();
+		} catch(FormatException expected) {}
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testNoFormatExceptionIfBatchIsMaximumSize() throws Exception {
+		final UnverifiedBatchFactory batchFactory =
+			context.mock(UnverifiedBatchFactory.class);
+		BatchReader batchReader = new BatchReader(messageReader, batchFactory);
+		final UnverifiedBatch batch = context.mock(UnverifiedBatch.class);
+		context.checking(new Expectations() {{
+			oneOf(batchFactory).createUnverifiedBatch(
+					Collections.singletonList(message));
+			will(returnValue(batch));
+		}});
+
+		byte[] b = createBatch(MAX_PACKET_LENGTH);
+		ByteArrayInputStream in = new ByteArrayInputStream(b);
+		Reader reader = readerFactory.createReader(in);
+		reader.addStructReader(Types.BATCH, batchReader);
+
+		assertEquals(batch, reader.readStruct(Types.BATCH,
+				UnverifiedBatch.class));
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testEmptyBatch() throws Exception {
+		final UnverifiedBatchFactory batchFactory =
+			context.mock(UnverifiedBatchFactory.class);
+		BatchReader batchReader = new BatchReader(messageReader, batchFactory);
+
+		byte[] b = createEmptyBatch();
+		ByteArrayInputStream in = new ByteArrayInputStream(b);
+		Reader reader = readerFactory.createReader(in);
+		reader.addStructReader(Types.BATCH, batchReader);
+
+		try {
+			reader.readStruct(Types.BATCH, UnverifiedBatch.class);
+			fail();
+		} catch(FormatException expected) {}
+		context.assertIsSatisfied();
+	}
+
+	private byte[] createBatch(int size) throws Exception {
+		ByteArrayOutputStream out = new ByteArrayOutputStream(size);
+		Writer w = writerFactory.createWriter(out);
+		w.writeStructId(Types.BATCH);
+		w.writeListStart();
+		// We're using a fake message reader, so it's OK to use a fake message
+		w.writeStructId(Types.MESSAGE);
+		w.writeBytes(new byte[size - 10]);
+		w.writeListEnd();
+		byte[] b = out.toByteArray();
+		assertEquals(size, b.length);
+		return b;
+	}
+
+	private byte[] createEmptyBatch() throws Exception {
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		Writer w = writerFactory.createWriter(out);
+		w.writeStructId(Types.BATCH);
+		w.writeListStart();
+		w.writeListEnd();
+		return out.toByteArray();
+	}
+
+	private class TestMessageReader implements StructReader<UnverifiedMessage> {
+
+		public UnverifiedMessage readStruct(Reader r) throws IOException {
+			r.readStructId(Types.MESSAGE);
+			r.readBytes();
+			return message;
+		}
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/protocol/ConstantsTest.java b/briar-tests/src/net/sf/briar/protocol/ConstantsTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..e4c6af3a1a9a52e32c31819b974701b0521addd4
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/protocol/ConstantsTest.java
@@ -0,0 +1,193 @@
+package net.sf.briar.protocol;
+
+import static net.sf.briar.api.protocol.ProtocolConstants.MAX_AUTHOR_NAME_LENGTH;
+import static net.sf.briar.api.protocol.ProtocolConstants.MAX_BODY_LENGTH;
+import static net.sf.briar.api.protocol.ProtocolConstants.MAX_GROUP_NAME_LENGTH;
+import static net.sf.briar.api.protocol.ProtocolConstants.MAX_PACKET_LENGTH;
+import static net.sf.briar.api.protocol.ProtocolConstants.MAX_PROPERTIES_PER_TRANSPORT;
+import static net.sf.briar.api.protocol.ProtocolConstants.MAX_PROPERTY_LENGTH;
+import static net.sf.briar.api.protocol.ProtocolConstants.MAX_PUBLIC_KEY_LENGTH;
+import static net.sf.briar.api.protocol.ProtocolConstants.MAX_SUBJECT_LENGTH;
+import static net.sf.briar.api.protocol.ProtocolConstants.MAX_TRANSPORTS;
+
+import java.io.ByteArrayOutputStream;
+import java.security.PrivateKey;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.TestUtils;
+import net.sf.briar.api.crypto.CryptoComponent;
+import net.sf.briar.api.protocol.Ack;
+import net.sf.briar.api.protocol.Author;
+import net.sf.briar.api.protocol.AuthorFactory;
+import net.sf.briar.api.protocol.BatchId;
+import net.sf.briar.api.protocol.Group;
+import net.sf.briar.api.protocol.GroupFactory;
+import net.sf.briar.api.protocol.Message;
+import net.sf.briar.api.protocol.MessageFactory;
+import net.sf.briar.api.protocol.MessageId;
+import net.sf.briar.api.protocol.Offer;
+import net.sf.briar.api.protocol.PacketFactory;
+import net.sf.briar.api.protocol.ProtocolWriter;
+import net.sf.briar.api.protocol.ProtocolWriterFactory;
+import net.sf.briar.api.protocol.RawBatch;
+import net.sf.briar.api.protocol.Transport;
+import net.sf.briar.api.protocol.TransportId;
+import net.sf.briar.api.protocol.TransportUpdate;
+import net.sf.briar.api.protocol.UniqueId;
+import net.sf.briar.crypto.CryptoModule;
+import net.sf.briar.serial.SerialModule;
+
+import org.junit.Test;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+
+public class ConstantsTest extends BriarTestCase {
+
+	private final CryptoComponent crypto;
+	private final GroupFactory groupFactory;
+	private final AuthorFactory authorFactory;
+	private final MessageFactory messageFactory;
+	private final PacketFactory packetFactory;
+	private final ProtocolWriterFactory protocolWriterFactory;
+
+	public ConstantsTest() throws Exception {
+		super();
+		Injector i = Guice.createInjector(new CryptoModule(),
+				new ProtocolModule(), new SerialModule());
+		crypto = i.getInstance(CryptoComponent.class);
+		groupFactory = i.getInstance(GroupFactory.class);
+		authorFactory = i.getInstance(AuthorFactory.class);
+		messageFactory = i.getInstance(MessageFactory.class);
+		packetFactory = i.getInstance(PacketFactory.class);
+		protocolWriterFactory = i.getInstance(ProtocolWriterFactory.class);
+	}
+
+	@Test
+	public void testBatchesFitIntoLargeAck() throws Exception {
+		testBatchesFitIntoAck(MAX_PACKET_LENGTH);
+	}
+
+	@Test
+	public void testBatchesFitIntoSmallAck() throws Exception {
+		testBatchesFitIntoAck(1000);
+	}
+
+	private void testBatchesFitIntoAck(int length) throws Exception {
+		// Create an ack with as many batch IDs as possible
+		ByteArrayOutputStream out = new ByteArrayOutputStream(length);
+		ProtocolWriter writer = protocolWriterFactory.createProtocolWriter(out,
+				true);
+		int maxBatches = writer.getMaxBatchesForAck(length);
+		Collection<BatchId> acked = new ArrayList<BatchId>();
+		for(int i = 0; i < maxBatches; i++) {
+			acked.add(new BatchId(TestUtils.getRandomId()));
+		}
+		Ack a = packetFactory.createAck(acked);
+		writer.writeAck(a);
+		// Check the size of the serialised ack
+		assertTrue(out.size() <= length);
+	}
+
+	@Test
+	public void testMessageFitsIntoBatch() throws Exception {
+		// Create a maximum-length group
+		String groupName = createRandomString(MAX_GROUP_NAME_LENGTH);
+		byte[] groupPublic = new byte[MAX_PUBLIC_KEY_LENGTH];
+		Group group = groupFactory.createGroup(groupName, groupPublic);
+		// Create a maximum-length author
+		String authorName = createRandomString(MAX_AUTHOR_NAME_LENGTH);
+		byte[] authorPublic = new byte[MAX_PUBLIC_KEY_LENGTH];
+		Author author = authorFactory.createAuthor(authorName, authorPublic);
+		// Create a maximum-length message
+		PrivateKey groupPrivate = crypto.generateSignatureKeyPair().getPrivate();
+		PrivateKey authorPrivate = crypto.generateSignatureKeyPair().getPrivate();
+		String subject = createRandomString(MAX_SUBJECT_LENGTH);
+		byte[] body = new byte[MAX_BODY_LENGTH];
+		Message message = messageFactory.createMessage(null, group,
+				groupPrivate, author, authorPrivate, subject, body);
+		// Add the message to a batch
+		ByteArrayOutputStream out =
+			new ByteArrayOutputStream(MAX_PACKET_LENGTH);
+		ProtocolWriter writer = protocolWriterFactory.createProtocolWriter(out,
+				true);
+		RawBatch b = packetFactory.createBatch(Collections.singletonList(
+				message.getSerialised()));
+		writer.writeBatch(b);
+		// Check the size of the serialised batch
+		assertTrue(out.size() > UniqueId.LENGTH + MAX_GROUP_NAME_LENGTH
+				+ MAX_PUBLIC_KEY_LENGTH + MAX_AUTHOR_NAME_LENGTH
+				+ MAX_PUBLIC_KEY_LENGTH + MAX_BODY_LENGTH);
+		assertTrue(out.size() <= MAX_PACKET_LENGTH);
+	}
+
+	@Test
+	public void testMessagesFitIntoLargeOffer() throws Exception {
+		testMessagesFitIntoOffer(MAX_PACKET_LENGTH);
+	}
+
+	@Test
+	public void testMessagesFitIntoSmallOffer() throws Exception {
+		testMessagesFitIntoOffer(1000);
+	}
+
+	private void testMessagesFitIntoOffer(int length) throws Exception {
+		// Create an offer with as many message IDs as possible
+		ByteArrayOutputStream out = new ByteArrayOutputStream(length);
+		ProtocolWriter writer = protocolWriterFactory.createProtocolWriter(out,
+				true);
+		int maxMessages = writer.getMaxMessagesForOffer(length);
+		Collection<MessageId> offered = new ArrayList<MessageId>();
+		for(int i = 0; i < maxMessages; i++) {
+			offered.add(new MessageId(TestUtils.getRandomId()));
+		}
+		Offer o = packetFactory.createOffer(offered);
+		writer.writeOffer(o);
+		// Check the size of the serialised offer
+		assertTrue(out.size() <= length);
+	}
+
+	@Test
+	public void testTransportsFitIntoUpdate() throws Exception {
+		// Create the maximum number of plugins, each with the maximum number
+		// of maximum-length properties
+		Collection<Transport> transports = new ArrayList<Transport>();
+		for(int i = 0; i < MAX_TRANSPORTS; i++) {
+			TransportId id = new TransportId(TestUtils.getRandomId());
+			Transport t = new Transport(id);
+			for(int j = 0; j < MAX_PROPERTIES_PER_TRANSPORT; j++) {
+				String key = createRandomString(MAX_PROPERTY_LENGTH);
+				String value = createRandomString(MAX_PROPERTY_LENGTH);
+				t.put(key, value);
+			}
+			transports.add(t);
+		}
+		// Add the transports to an update
+		ByteArrayOutputStream out =
+			new ByteArrayOutputStream(MAX_PACKET_LENGTH);
+		ProtocolWriter writer = protocolWriterFactory.createProtocolWriter(out,
+				true);
+		TransportUpdate t = packetFactory.createTransportUpdate(transports,
+				Long.MAX_VALUE);
+		writer.writeTransportUpdate(t);
+		// Check the size of the serialised update
+		assertTrue(out.size() > MAX_TRANSPORTS * (UniqueId.LENGTH + 4
+				+ (MAX_PROPERTIES_PER_TRANSPORT * MAX_PROPERTY_LENGTH * 2))
+				+ 8);
+		assertTrue(out.size() <= MAX_PACKET_LENGTH);
+	}
+
+	private static String createRandomString(int length) throws Exception {
+		StringBuilder s = new StringBuilder(length);
+		for(int i = 0; i < length; i++) {
+			int digit = (int) (Math.random() * 10);
+			s.append((char) ('0' + digit));
+		}
+		String string = s.toString();
+		assertEquals(length, string.getBytes("UTF-8").length);
+		return string;
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/protocol/ConsumersTest.java b/briar-tests/src/net/sf/briar/protocol/ConsumersTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..0bb89d6d29a3fe4e0a79e9bd2b561691fd978cdb
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/protocol/ConsumersTest.java
@@ -0,0 +1,105 @@
+package net.sf.briar.protocol;
+
+import static org.junit.Assert.assertArrayEquals;
+
+import java.security.GeneralSecurityException;
+import java.util.Random;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.api.FormatException;
+import net.sf.briar.api.crypto.MessageDigest;
+import net.sf.briar.api.serial.CopyingConsumer;
+import net.sf.briar.api.serial.CountingConsumer;
+import net.sf.briar.api.serial.DigestingConsumer;
+
+import org.junit.Test;
+
+public class ConsumersTest extends BriarTestCase {
+
+	@Test
+	public void testDigestingConsumer() throws Exception {
+		byte[] data = new byte[1234];
+		// Generate some random data and digest it
+		new Random().nextBytes(data);
+		MessageDigest messageDigest = new TestMessageDigest();
+		messageDigest.update(data);
+		byte[] dig = messageDigest.digest();
+		// Check that feeding a DigestingConsumer generates the same digest
+		DigestingConsumer dc = new DigestingConsumer(messageDigest);
+		dc.write(data[0]);
+		dc.write(data, 1, data.length - 2);
+		dc.write(data[data.length - 1]);
+		byte[] dig1 = messageDigest.digest();
+		assertArrayEquals(dig, dig1);
+	}
+
+	@Test
+	public void testCountingConsumer() throws Exception {
+		byte[] data = new byte[1234];
+		CountingConsumer cc = new CountingConsumer(data.length);
+		cc.write(data[0]);
+		cc.write(data, 1, data.length - 2);
+		cc.write(data[data.length - 1]);
+		assertEquals(data.length, cc.getCount());
+		try {
+			cc.write((byte) 0);
+			fail();
+		} catch(FormatException expected) {}
+	}
+
+	@Test
+	public void testCopyingConsumer() throws Exception {
+		byte[] data = new byte[1234];
+		new Random().nextBytes(data);
+		// Check that a CopyingConsumer creates a faithful copy
+		CopyingConsumer cc = new CopyingConsumer();
+		cc.write(data[0]);
+		cc.write(data, 1, data.length - 2);
+		cc.write(data[data.length - 1]);
+		assertArrayEquals(data, cc.getCopy());
+	}
+
+	private static class TestMessageDigest implements MessageDigest {
+
+		private final java.security.MessageDigest delegate;
+
+		private TestMessageDigest() throws GeneralSecurityException {
+			delegate = java.security.MessageDigest.getInstance("SHA-256");
+		}
+
+		public byte[] digest() {
+			return delegate.digest();
+		}
+
+		public byte[] digest(byte[] input) {
+			return delegate.digest(input);
+		}
+
+		public int digest(byte[] buf, int offset, int len) {
+			byte[] digest = digest();
+			len = Math.min(len, digest.length);
+			System.arraycopy(digest, 0, buf, offset, len);
+			return len;
+		}
+
+		public int getDigestLength() {
+			return delegate.getDigestLength();
+		}
+
+		public void reset() {
+			delegate.reset();
+		}
+
+		public void update(byte input) {
+			delegate.update(input);
+		}
+
+		public void update(byte[] input) {
+			delegate.update(input);
+		}
+
+		public void update(byte[] input, int offset, int len) {
+			delegate.update(input, offset, len);
+		}		
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/protocol/OfferReaderTest.java b/briar-tests/src/net/sf/briar/protocol/OfferReaderTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..d3ecbc0fbc96e098dd52182e7317c03a123f6755
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/protocol/OfferReaderTest.java
@@ -0,0 +1,124 @@
+package net.sf.briar.protocol;
+
+import static net.sf.briar.api.protocol.ProtocolConstants.MAX_PACKET_LENGTH;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.util.Collection;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.TestUtils;
+import net.sf.briar.api.FormatException;
+import net.sf.briar.api.protocol.Offer;
+import net.sf.briar.api.protocol.PacketFactory;
+import net.sf.briar.api.protocol.Types;
+import net.sf.briar.api.serial.Reader;
+import net.sf.briar.api.serial.ReaderFactory;
+import net.sf.briar.api.serial.SerialComponent;
+import net.sf.briar.api.serial.Writer;
+import net.sf.briar.api.serial.WriterFactory;
+import net.sf.briar.serial.SerialModule;
+
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.junit.Test;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+
+public class OfferReaderTest extends BriarTestCase {
+
+	// FIXME: This is an integration test, not a unit test
+
+	private final SerialComponent serial;
+	private final ReaderFactory readerFactory;
+	private final WriterFactory writerFactory;
+	private final Mockery context;
+
+	public OfferReaderTest() throws Exception {
+		super();
+		Injector i = Guice.createInjector(new SerialModule());
+		serial = i.getInstance(SerialComponent.class);
+		readerFactory = i.getInstance(ReaderFactory.class);
+		writerFactory = i.getInstance(WriterFactory.class);
+		context = new Mockery();
+	}
+
+	@Test
+	public void testFormatExceptionIfOfferIsTooLarge() throws Exception {
+		PacketFactory packetFactory = context.mock(PacketFactory.class);
+		OfferReader offerReader = new OfferReader(packetFactory);
+
+		byte[] b = createOffer(true);
+		ByteArrayInputStream in = new ByteArrayInputStream(b);
+		Reader reader = readerFactory.createReader(in);
+		reader.addStructReader(Types.OFFER, offerReader);
+
+		try {
+			reader.readStruct(Types.OFFER, Offer.class);
+			fail();
+		} catch(FormatException expected) {}
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	public void testNoFormatExceptionIfOfferIsMaximumSize() throws Exception {
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		OfferReader offerReader = new OfferReader(packetFactory);
+		final Offer offer = context.mock(Offer.class);
+		context.checking(new Expectations() {{
+			oneOf(packetFactory).createOffer(with(any(Collection.class)));
+			will(returnValue(offer));
+		}});
+
+		byte[] b = createOffer(false);
+		ByteArrayInputStream in = new ByteArrayInputStream(b);
+		Reader reader = readerFactory.createReader(in);
+		reader.addStructReader(Types.OFFER, offerReader);
+
+		assertEquals(offer, reader.readStruct(Types.OFFER, Offer.class));
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testEmptyOffer() throws Exception {
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		OfferReader offerReader = new OfferReader(packetFactory);
+
+		byte[] b = createEmptyOffer();
+		ByteArrayInputStream in = new ByteArrayInputStream(b);
+		Reader reader = readerFactory.createReader(in);
+		reader.addStructReader(Types.OFFER, offerReader);
+
+		try {
+			reader.readStruct(Types.OFFER, Offer.class);
+			fail();
+		} catch(FormatException expected) {}
+		context.assertIsSatisfied();
+	}
+
+	private byte[] createOffer(boolean tooBig) throws Exception {
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		Writer w = writerFactory.createWriter(out);
+		w.writeStructId(Types.OFFER);
+		w.writeListStart();
+		while(out.size() + serial.getSerialisedUniqueIdLength()
+				< MAX_PACKET_LENGTH) {
+			w.writeBytes(TestUtils.getRandomId());
+		}
+		if(tooBig) w.writeBytes(TestUtils.getRandomId());
+		w.writeListEnd();
+		assertEquals(tooBig, out.size() > MAX_PACKET_LENGTH);
+		return out.toByteArray();
+	}
+
+	private byte[] createEmptyOffer() throws Exception {
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		Writer w = writerFactory.createWriter(out);
+		w.writeStructId(Types.OFFER);
+		w.writeListStart();
+		w.writeListEnd();
+		return out.toByteArray();
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/protocol/ProtocolIntegrationTest.java b/briar-tests/src/net/sf/briar/protocol/ProtocolIntegrationTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..4c939bc9c40a38a5b3e2afe0ad8f88e3415b12b8
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/protocol/ProtocolIntegrationTest.java
@@ -0,0 +1,133 @@
+package net.sf.briar.protocol;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.util.BitSet;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.TestUtils;
+import net.sf.briar.api.protocol.Ack;
+import net.sf.briar.api.protocol.Batch;
+import net.sf.briar.api.protocol.BatchId;
+import net.sf.briar.api.protocol.Group;
+import net.sf.briar.api.protocol.GroupFactory;
+import net.sf.briar.api.protocol.GroupId;
+import net.sf.briar.api.protocol.Message;
+import net.sf.briar.api.protocol.MessageFactory;
+import net.sf.briar.api.protocol.Offer;
+import net.sf.briar.api.protocol.PacketFactory;
+import net.sf.briar.api.protocol.ProtocolReader;
+import net.sf.briar.api.protocol.ProtocolReaderFactory;
+import net.sf.briar.api.protocol.ProtocolWriter;
+import net.sf.briar.api.protocol.ProtocolWriterFactory;
+import net.sf.briar.api.protocol.RawBatch;
+import net.sf.briar.api.protocol.Request;
+import net.sf.briar.api.protocol.SubscriptionUpdate;
+import net.sf.briar.api.protocol.Transport;
+import net.sf.briar.api.protocol.TransportId;
+import net.sf.briar.api.protocol.TransportUpdate;
+import net.sf.briar.crypto.CryptoModule;
+import net.sf.briar.serial.SerialModule;
+
+import org.junit.Test;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+
+public class ProtocolIntegrationTest extends BriarTestCase {
+
+	private final ProtocolReaderFactory readerFactory;
+	private final ProtocolWriterFactory writerFactory;
+	private final PacketFactory packetFactory;
+	private final BatchId batchId;
+	private final Group group;
+	private final Message message;
+	private final String subject = "Hello";
+	private final String messageBody = "Hello world";
+	private final BitSet bitSet;
+	private final Map<Group, Long> subscriptions;
+	private final Collection<Transport> transports;
+	private final long timestamp = System.currentTimeMillis();
+
+	public ProtocolIntegrationTest() throws Exception {
+		super();
+		Injector i = Guice.createInjector(new CryptoModule(),
+				new ProtocolModule(), new SerialModule());
+		readerFactory = i.getInstance(ProtocolReaderFactory.class);
+		writerFactory = i.getInstance(ProtocolWriterFactory.class);
+		packetFactory = i.getInstance(PacketFactory.class);
+		batchId = new BatchId(TestUtils.getRandomId());
+		GroupFactory groupFactory = i.getInstance(GroupFactory.class);
+		group = groupFactory.createGroup("Unrestricted group", null);
+		MessageFactory messageFactory = i.getInstance(MessageFactory.class);
+		message = messageFactory.createMessage(null, group, subject,
+				messageBody.getBytes("UTF-8"));
+		bitSet = new BitSet();
+		bitSet.set(3);
+		bitSet.set(7);
+		subscriptions = Collections.singletonMap(group, 123L);
+		TransportId transportId = new TransportId(TestUtils.getRandomId());
+		Transport transport = new Transport(transportId,
+				Collections.singletonMap("bar", "baz"));
+		transports = Collections.singletonList(transport);
+	}
+
+	@Test
+	public void testWriteAndRead() throws Exception {
+		// Write
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		ProtocolWriter writer = writerFactory.createProtocolWriter(out, true);
+
+		Ack a = packetFactory.createAck(Collections.singletonList(batchId));
+		writer.writeAck(a);
+
+		RawBatch b = packetFactory.createBatch(Collections.singletonList(
+				message.getSerialised()));
+		writer.writeBatch(b);
+
+		Offer o = packetFactory.createOffer(Collections.singletonList(
+				message.getId()));
+		writer.writeOffer(o);
+
+		Request r = packetFactory.createRequest(bitSet, 10);
+		writer.writeRequest(r);
+
+		SubscriptionUpdate s = packetFactory.createSubscriptionUpdate(
+				Collections.<GroupId, GroupId>emptyMap(), subscriptions, 0L,
+				timestamp);
+		writer.writeSubscriptionUpdate(s);
+
+		TransportUpdate t = packetFactory.createTransportUpdate(transports,
+				timestamp);
+		writer.writeTransportUpdate(t);
+
+		// Read
+		ByteArrayInputStream in = new ByteArrayInputStream(out.toByteArray());
+		ProtocolReader reader = readerFactory.createProtocolReader(in);
+
+		a = reader.readAck();
+		assertEquals(Collections.singletonList(batchId), a.getBatchIds());
+
+		Batch b1 = reader.readBatch().verify();
+		assertEquals(Collections.singletonList(message), b1.getMessages());
+
+		o = reader.readOffer();
+		assertEquals(Collections.singletonList(message.getId()),
+				o.getMessageIds());
+
+		r = reader.readRequest();
+		assertEquals(bitSet, r.getBitmap());
+		assertEquals(10, r.getLength());
+
+		s = reader.readSubscriptionUpdate();
+		assertEquals(subscriptions, s.getSubscriptions());
+		assertEquals(timestamp, s.getTimestamp());
+
+		t = reader.readTransportUpdate();
+		assertEquals(transports, t.getTransports());
+		assertEquals(timestamp, t.getTimestamp());
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/protocol/ProtocolWriterImplTest.java b/briar-tests/src/net/sf/briar/protocol/ProtocolWriterImplTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..4f2343b19a38dc3fd25ffb7fa6c412642047efa8
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/protocol/ProtocolWriterImplTest.java
@@ -0,0 +1,87 @@
+package net.sf.briar.protocol;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.BitSet;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.api.protocol.PacketFactory;
+import net.sf.briar.api.protocol.ProtocolWriter;
+import net.sf.briar.api.protocol.Request;
+import net.sf.briar.api.serial.SerialComponent;
+import net.sf.briar.api.serial.WriterFactory;
+import net.sf.briar.crypto.CryptoModule;
+import net.sf.briar.serial.SerialModule;
+import net.sf.briar.util.StringUtils;
+
+import org.junit.Test;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+
+public class ProtocolWriterImplTest extends BriarTestCase {
+
+	// FIXME: This is an integration test, not a unit test
+
+	private final PacketFactory packetFactory;
+	private final SerialComponent serial;
+	private final WriterFactory writerFactory;
+
+	public ProtocolWriterImplTest() {
+		super();
+		Injector i = Guice.createInjector(new CryptoModule(),
+				new ProtocolModule(), new SerialModule());
+		packetFactory = i.getInstance(PacketFactory.class);
+		serial = i.getInstance(SerialComponent.class);
+		writerFactory = i.getInstance(WriterFactory.class);
+	}
+
+	@Test
+	public void testWriteBitmapNoPadding() throws IOException {
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		ProtocolWriter w = new ProtocolWriterImpl(serial, writerFactory, out,
+				true);
+		BitSet b = new BitSet();
+		// 11011001 = 0xD9
+		b.set(0);
+		b.set(1);
+		b.set(3);
+		b.set(4);
+		b.set(7);
+		// 01011001 = 0x59
+		b.set(9);
+		b.set(11);
+		b.set(12);
+		b.set(15);
+		Request r = packetFactory.createRequest(b, 16);
+		w.writeRequest(r);
+		// Short user tag 6, 0 as uint7, short bytes with length 2, 0xD959
+		byte[] output = out.toByteArray();
+		assertEquals("C6" + "00" + "92" + "D959",
+				StringUtils.toHexString(output));
+	}
+
+	@Test
+	public void testWriteBitmapWithPadding() throws IOException {
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		ProtocolWriter w = new ProtocolWriterImpl(serial, writerFactory, out,
+				true);
+		BitSet b = new BitSet();
+		// 01011001 = 0x59
+		b.set(1);
+		b.set(3);
+		b.set(4);
+		b.set(7);
+		// 11011xxx = 0xD8, after padding
+		b.set(8);
+		b.set(9);
+		b.set(11);
+		b.set(12);
+		Request r = packetFactory.createRequest(b, 13);
+		w.writeRequest(r);
+		// Short user tag 6, 3 as uint7, short bytes with length 2, 0x59D8
+		byte[] output = out.toByteArray();
+		assertEquals("C6" + "03" + "92" + "59D8",
+				StringUtils.toHexString(output));
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/protocol/RequestReaderTest.java b/briar-tests/src/net/sf/briar/protocol/RequestReaderTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..7dc377ecd411d94ea21bf11ad17e35feadfc47c1
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/protocol/RequestReaderTest.java
@@ -0,0 +1,146 @@
+package net.sf.briar.protocol;
+
+import static net.sf.briar.api.protocol.ProtocolConstants.MAX_PACKET_LENGTH;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.util.BitSet;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.api.FormatException;
+import net.sf.briar.api.protocol.PacketFactory;
+import net.sf.briar.api.protocol.Request;
+import net.sf.briar.api.protocol.Types;
+import net.sf.briar.api.serial.Reader;
+import net.sf.briar.api.serial.ReaderFactory;
+import net.sf.briar.api.serial.Writer;
+import net.sf.briar.api.serial.WriterFactory;
+import net.sf.briar.crypto.CryptoModule;
+import net.sf.briar.serial.SerialModule;
+
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.junit.Test;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+
+public class RequestReaderTest extends BriarTestCase {
+
+	// FIXME: This is an integration test, not a unit test
+
+	private final ReaderFactory readerFactory;
+	private final WriterFactory writerFactory;
+	private final PacketFactory packetFactory;
+	private final Mockery context;
+
+	public RequestReaderTest() throws Exception {
+		super();
+		Injector i = Guice.createInjector(new CryptoModule(),
+				new ProtocolModule(), new SerialModule());
+		readerFactory = i.getInstance(ReaderFactory.class);
+		writerFactory = i.getInstance(WriterFactory.class);
+		packetFactory = i.getInstance(PacketFactory.class);
+		context = new Mockery();
+	}
+
+	@Test
+	public void testFormatExceptionIfRequestIsTooLarge() throws Exception {
+		PacketFactory packetFactory = context.mock(PacketFactory.class);
+		RequestReader requestReader = new RequestReader(packetFactory);
+
+		byte[] b = createRequest(true);
+		ByteArrayInputStream in = new ByteArrayInputStream(b);
+		Reader reader = readerFactory.createReader(in);
+		reader.addStructReader(Types.REQUEST, requestReader);
+
+		try {
+			reader.readStruct(Types.REQUEST, Request.class);
+			fail();
+		} catch(FormatException expected) {}
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testNoFormatExceptionIfRequestIsMaximumSize() throws Exception {
+		final PacketFactory packetFactory = context.mock(PacketFactory.class);
+		RequestReader requestReader = new RequestReader(packetFactory);
+		final Request request = context.mock(Request.class);
+		context.checking(new Expectations() {{
+			oneOf(packetFactory).createRequest(with(any(BitSet.class)),
+					with(any(int.class)));
+			will(returnValue(request));
+		}});
+
+		byte[] b = createRequest(false);
+		ByteArrayInputStream in = new ByteArrayInputStream(b);
+		Reader reader = readerFactory.createReader(in);
+		reader.addStructReader(Types.REQUEST, requestReader);
+
+		assertEquals(request, reader.readStruct(Types.REQUEST,
+				Request.class));
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testBitmapDecoding() throws Exception {
+		// Test sizes from 0 to 1000 bits
+		for(int i = 0; i < 1000; i++) {
+			// Create a BitSet of size i with one in ten bits set (on average)
+			BitSet requested = new BitSet(i);
+			for(int j = 0; j < i; j++) if(Math.random() < 0.1) requested.set(j);
+			// Encode the BitSet as a bitmap
+			int bytes = i % 8 == 0 ? i / 8 : i / 8 + 1;
+			byte[] bitmap = new byte[bytes];
+			for(int j = 0; j < i; j++) {
+				if(requested.get(j)) {
+					int offset = j / 8;
+					byte bit = (byte) (128 >> j % 8);
+					bitmap[offset] |= bit;
+				}
+			}
+			// Create a serialised request containing the bitmap
+			byte[] b = createRequest(bitmap);
+			// Deserialise the request
+			ByteArrayInputStream in = new ByteArrayInputStream(b);
+			Reader reader = readerFactory.createReader(in);
+			RequestReader requestReader = new RequestReader(packetFactory);
+			reader.addStructReader(Types.REQUEST, requestReader);
+			Request r = reader.readStruct(Types.REQUEST, Request.class);
+			BitSet decoded = r.getBitmap();
+			// Check that the decoded BitSet matches the original - we can't
+			// use equals() because of padding, but the first i bits should
+			// match and the cardinalities should be equal, indicating that no
+			// padding bits are set
+			for(int j = 0; j < i; j++) {
+				assertEquals(requested.get(j), decoded.get(j));
+			}
+			assertEquals(requested.cardinality(), decoded.cardinality());
+		}
+	}
+
+	private byte[] createRequest(boolean tooBig) throws Exception {
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		Writer w = writerFactory.createWriter(out);
+		w.writeStructId(Types.REQUEST);
+		// Allow one byte for the REQUEST tag, one byte for the padding length
+		// as a uint7, one byte for the BYTES tag, and five bytes for the
+		// length of the byte array as an int32
+		int size = MAX_PACKET_LENGTH - 8;
+		if(tooBig) size++;
+		assertTrue(size > Short.MAX_VALUE);
+		w.writeUint7((byte) 0);
+		w.writeBytes(new byte[size]);
+		assertEquals(tooBig, out.size() > MAX_PACKET_LENGTH);
+		return out.toByteArray();
+	}
+
+	private byte[] createRequest(byte[] bitmap) throws Exception {
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		Writer w = writerFactory.createWriter(out);
+		w.writeStructId(Types.REQUEST);
+		w.writeUint7((byte) 0);
+		w.writeBytes(bitmap);
+		return out.toByteArray();
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/protocol/UnverifiedBatchImplTest.java b/briar-tests/src/net/sf/briar/protocol/UnverifiedBatchImplTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..126f8a983aa3894f385a1f694ce90d8afb90cda1
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/protocol/UnverifiedBatchImplTest.java
@@ -0,0 +1,244 @@
+package net.sf.briar.protocol;
+
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.security.Signature;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.Random;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.TestUtils;
+import net.sf.briar.api.crypto.CryptoComponent;
+import net.sf.briar.api.crypto.MessageDigest;
+import net.sf.briar.api.protocol.Author;
+import net.sf.briar.api.protocol.AuthorId;
+import net.sf.briar.api.protocol.Batch;
+import net.sf.briar.api.protocol.BatchId;
+import net.sf.briar.api.protocol.Group;
+import net.sf.briar.api.protocol.GroupId;
+import net.sf.briar.api.protocol.Message;
+import net.sf.briar.api.protocol.MessageId;
+import net.sf.briar.api.protocol.UnverifiedBatch;
+import net.sf.briar.crypto.CryptoModule;
+
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.junit.Test;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+
+public class UnverifiedBatchImplTest extends BriarTestCase {
+
+	// FIXME: This is an integration test, not a unit test
+
+	private final CryptoComponent crypto;
+	private final byte[] raw, raw1;
+	private final String subject;
+	private final long timestamp;
+
+	public UnverifiedBatchImplTest() {
+		super();
+		Injector i = Guice.createInjector(new CryptoModule());
+		crypto = i.getInstance(CryptoComponent.class);
+		Random r = new Random();
+		raw = new byte[123];
+		r.nextBytes(raw);
+		raw1 = new byte[1234];
+		r.nextBytes(raw1);
+		subject = "Unit tests are exciting";
+		timestamp = System.currentTimeMillis();
+	}
+
+	@Test
+	public void testIds() throws Exception {
+		// Calculate the expected batch and message IDs
+		MessageDigest messageDigest = crypto.getMessageDigest();
+		messageDigest.update(raw);
+		messageDigest.update(raw1);
+		BatchId batchId = new BatchId(messageDigest.digest());
+		messageDigest.update(raw);
+		MessageId messageId = new MessageId(messageDigest.digest());
+		messageDigest.update(raw1);
+		MessageId messageId1 = new MessageId(messageDigest.digest());
+		// Verify the batch
+		Mockery context = new Mockery();
+		final UnverifiedMessage message =
+				context.mock(UnverifiedMessage.class, "message");
+		final UnverifiedMessage message1 =
+				context.mock(UnverifiedMessage.class, "message1");
+		context.checking(new Expectations() {{
+			// First message
+			oneOf(message).getRaw();
+			will(returnValue(raw));
+			oneOf(message).getAuthor();
+			will(returnValue(null));
+			oneOf(message).getGroup();
+			will(returnValue(null));
+			oneOf(message).getParent();
+			will(returnValue(null));
+			oneOf(message).getSubject();
+			will(returnValue(subject));
+			oneOf(message).getTimestamp();
+			will(returnValue(timestamp));
+			oneOf(message).getBodyStart();
+			will(returnValue(10));
+			oneOf(message).getBodyLength();
+			will(returnValue(100));
+			// Second message
+			oneOf(message1).getRaw();
+			will(returnValue(raw1));
+			oneOf(message1).getAuthor();
+			will(returnValue(null));
+			oneOf(message1).getGroup();
+			will(returnValue(null));
+			oneOf(message1).getParent();
+			will(returnValue(null));
+			oneOf(message1).getSubject();
+			will(returnValue(subject));
+			oneOf(message1).getTimestamp();
+			will(returnValue(timestamp));
+			oneOf(message1).getBodyStart();
+			will(returnValue(10));
+			oneOf(message1).getBodyLength();
+			will(returnValue(1000));
+		}});
+		Collection<UnverifiedMessage> messages = Arrays.asList(message,
+				message1);
+		UnverifiedBatch batch = new UnverifiedBatchImpl(crypto, messages);
+		Batch verifiedBatch = batch.verify();
+		// Check that the batch and message IDs match
+		assertEquals(batchId, verifiedBatch.getId());
+		Collection<Message> verifiedMessages = verifiedBatch.getMessages();
+		assertEquals(2, verifiedMessages.size());
+		Iterator<Message> it = verifiedMessages.iterator();
+		Message verifiedMessage = it.next();
+		assertEquals(messageId, verifiedMessage.getId());
+		Message verifiedMessage1 = it.next();
+		assertEquals(messageId1, verifiedMessage1.getId());
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testSignatures() throws Exception {
+		final int signedByAuthor = 100, signedByGroup = 110;
+		final KeyPair authorKeyPair = crypto.generateSignatureKeyPair();
+		final KeyPair groupKeyPair = crypto.generateSignatureKeyPair();
+		Signature signature = crypto.getSignature();
+		// Calculate the expected author and group signatures
+		signature.initSign(authorKeyPair.getPrivate());
+		signature.update(raw, 0, signedByAuthor);
+		final byte[] authorSignature = signature.sign();
+		signature.initSign(groupKeyPair.getPrivate());
+		signature.update(raw, 0, signedByGroup);
+		final byte[] groupSignature = signature.sign();
+		// Verify the batch
+		Mockery context = new Mockery();
+		final UnverifiedMessage message =
+				context.mock(UnverifiedMessage.class, "message");
+		final Author author = context.mock(Author.class);
+		final Group group = context.mock(Group.class);
+		final UnverifiedMessage message1 =
+				context.mock(UnverifiedMessage.class, "message1");
+		context.checking(new Expectations() {{
+			// First message
+			oneOf(message).getRaw();
+			will(returnValue(raw));
+			oneOf(message).getAuthor();
+			will(returnValue(author));
+			oneOf(author).getPublicKey();
+			will(returnValue(authorKeyPair.getPublic().getEncoded()));
+			oneOf(message).getLengthSignedByAuthor();
+			will(returnValue(signedByAuthor));
+			oneOf(message).getAuthorSignature();
+			will(returnValue(authorSignature));
+			oneOf(message).getGroup();
+			will(returnValue(group));
+			exactly(2).of(group).getPublicKey();
+			will(returnValue(groupKeyPair.getPublic().getEncoded()));
+			oneOf(message).getLengthSignedByGroup();
+			will(returnValue(signedByGroup));
+			oneOf(message).getGroupSignature();
+			will(returnValue(groupSignature));
+			oneOf(author).getId();
+			will(returnValue(new AuthorId(TestUtils.getRandomId())));
+			oneOf(group).getId();
+			will(returnValue(new GroupId(TestUtils.getRandomId())));
+			oneOf(message).getParent();
+			will(returnValue(null));
+			oneOf(message).getSubject();
+			will(returnValue(subject));
+			oneOf(message).getTimestamp();
+			will(returnValue(timestamp));
+			oneOf(message).getBodyStart();
+			will(returnValue(10));
+			oneOf(message).getBodyLength();
+			will(returnValue(100));
+			// Second message
+			oneOf(message1).getRaw();
+			will(returnValue(raw1));
+			oneOf(message1).getAuthor();
+			will(returnValue(null));
+			oneOf(message1).getGroup();
+			will(returnValue(null));
+			oneOf(message1).getParent();
+			will(returnValue(null));
+			oneOf(message1).getSubject();
+			will(returnValue(subject));
+			oneOf(message1).getTimestamp();
+			will(returnValue(timestamp));
+			oneOf(message1).getBodyStart();
+			will(returnValue(10));
+			oneOf(message1).getBodyLength();
+			will(returnValue(1000));
+		}});
+		Collection<UnverifiedMessage> messages = Arrays.asList(message,
+				message1);
+		UnverifiedBatch batch = new UnverifiedBatchImpl(crypto, messages);
+		batch.verify();
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testExceptionThrownIfMessageIsModified() throws Exception {
+		final int signedByAuthor = 100;
+		final KeyPair authorKeyPair = crypto.generateSignatureKeyPair();
+		Signature signature = crypto.getSignature();
+		// Calculate the expected author signature
+		signature.initSign(authorKeyPair.getPrivate());
+		signature.update(raw, 0, signedByAuthor);
+		final byte[] authorSignature = signature.sign();
+		// Modify the message
+		raw[signedByAuthor / 2] ^= 0xff;
+		// Verify the batch
+		Mockery context = new Mockery();
+		final UnverifiedMessage message =
+				context.mock(UnverifiedMessage.class, "message");
+		final Author author = context.mock(Author.class);
+		final UnverifiedMessage message1 =
+				context.mock(UnverifiedMessage.class, "message1");
+		context.checking(new Expectations() {{
+			// First message - verification will fail at the author's signature
+			oneOf(message).getRaw();
+			will(returnValue(raw));
+			oneOf(message).getAuthor();
+			will(returnValue(author));
+			oneOf(author).getPublicKey();
+			will(returnValue(authorKeyPair.getPublic().getEncoded()));
+			oneOf(message).getLengthSignedByAuthor();
+			will(returnValue(signedByAuthor));
+			oneOf(message).getAuthorSignature();
+			will(returnValue(authorSignature));
+		}});
+		Collection<UnverifiedMessage> messages = Arrays.asList(message,
+				message1);
+		UnverifiedBatch batch = new UnverifiedBatchImpl(crypto, messages);
+		try {
+			batch.verify();
+			fail();
+		} catch(GeneralSecurityException expected) {}
+		context.assertIsSatisfied();
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/protocol/simplex/OutgoingSimplexConnectionTest.java b/briar-tests/src/net/sf/briar/protocol/simplex/OutgoingSimplexConnectionTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..bc0491b2e0b5fea2e27b87bdb440df9588f2201b
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/protocol/simplex/OutgoingSimplexConnectionTest.java
@@ -0,0 +1,177 @@
+package net.sf.briar.protocol.simplex;
+
+import static net.sf.briar.api.protocol.ProtocolConstants.MAX_PACKET_LENGTH;
+import static net.sf.briar.api.transport.TransportConstants.HEADER_LENGTH;
+import static net.sf.briar.api.transport.TransportConstants.MAC_LENGTH;
+import static net.sf.briar.api.transport.TransportConstants.MIN_CONNECTION_LENGTH;
+import static net.sf.briar.api.transport.TransportConstants.TAG_LENGTH;
+
+import java.io.ByteArrayOutputStream;
+import java.util.Collections;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.TestUtils;
+import net.sf.briar.api.ContactId;
+import net.sf.briar.api.db.DatabaseComponent;
+import net.sf.briar.api.db.DatabaseExecutor;
+import net.sf.briar.api.protocol.Ack;
+import net.sf.briar.api.protocol.BatchId;
+import net.sf.briar.api.protocol.ProtocolWriterFactory;
+import net.sf.briar.api.protocol.RawBatch;
+import net.sf.briar.api.protocol.TransportId;
+import net.sf.briar.api.protocol.UniqueId;
+import net.sf.briar.api.transport.ConnectionContext;
+import net.sf.briar.api.transport.ConnectionRegistry;
+import net.sf.briar.api.transport.ConnectionWriterFactory;
+import net.sf.briar.clock.ClockModule;
+import net.sf.briar.crypto.CryptoModule;
+import net.sf.briar.protocol.ProtocolModule;
+import net.sf.briar.protocol.duplex.DuplexProtocolModule;
+import net.sf.briar.serial.SerialModule;
+import net.sf.briar.transport.TransportModule;
+
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.junit.Test;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.Module;
+
+public class OutgoingSimplexConnectionTest extends BriarTestCase {
+
+	// FIXME: This is an integration test, not a unit test
+
+	private final Mockery context;
+	private final DatabaseComponent db;
+	private final ConnectionRegistry connRegistry;
+	private final ConnectionWriterFactory connFactory;
+	private final ProtocolWriterFactory protoFactory;
+	private final ContactId contactId;
+	private final TransportId transportId;
+	private final byte[] secret;
+
+	public OutgoingSimplexConnectionTest() {
+		super();
+		context = new Mockery();
+		db = context.mock(DatabaseComponent.class);
+		Module testModule = new AbstractModule() {
+			@Override
+			public void configure() {
+				bind(DatabaseComponent.class).toInstance(db);
+				bind(Executor.class).annotatedWith(
+						DatabaseExecutor.class).toInstance(
+								Executors.newCachedThreadPool());
+			}
+		};
+		Injector i = Guice.createInjector(testModule, new ClockModule(),
+				new CryptoModule(), new SerialModule(), new TransportModule(),
+				new SimplexProtocolModule(), new ProtocolModule(),
+				new DuplexProtocolModule());
+		connRegistry = i.getInstance(ConnectionRegistry.class);
+		connFactory = i.getInstance(ConnectionWriterFactory.class);
+		protoFactory = i.getInstance(ProtocolWriterFactory.class);
+		contactId = new ContactId(234);
+		transportId = new TransportId(TestUtils.getRandomId());
+		secret = new byte[32];
+	}
+
+	@Test
+	public void testConnectionTooShort() throws Exception {
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		TestSimplexTransportWriter transport = new TestSimplexTransportWriter(
+				out, MAX_PACKET_LENGTH, true);
+		ConnectionContext ctx = new ConnectionContext(contactId, transportId,
+				secret, 0L, true);
+		OutgoingSimplexConnection connection = new OutgoingSimplexConnection(db,
+				connRegistry, connFactory, protoFactory, ctx, transport);
+		connection.write();
+		// Nothing should have been written
+		assertEquals(0, out.size());
+		// The transport should have been disposed with exception == true
+		assertTrue(transport.getDisposed());
+		assertTrue(transport.getException());
+	}
+
+	@Test
+	public void testNothingToSend() throws Exception {
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		TestSimplexTransportWriter transport = new TestSimplexTransportWriter(
+				out, MIN_CONNECTION_LENGTH, true);
+		ConnectionContext ctx = new ConnectionContext(contactId, transportId,
+				secret, 0L, true);
+		OutgoingSimplexConnection connection = new OutgoingSimplexConnection(db,
+				connRegistry, connFactory, protoFactory, ctx, transport);
+		context.checking(new Expectations() {{
+			// No transports to send
+			oneOf(db).generateTransportUpdate(contactId);
+			will(returnValue(null));
+			// No subscriptions to send
+			oneOf(db).generateSubscriptionUpdate(contactId);
+			will(returnValue(null));
+			// No acks to send
+			oneOf(db).generateAck(with(contactId), with(any(int.class)));
+			will(returnValue(null));
+			// No batches to send
+			oneOf(db).generateBatch(with(contactId), with(any(int.class)));
+			will(returnValue(null));
+		}});
+		connection.write();
+		// Nothing should have been written
+		assertEquals(0, out.size());
+		// The transport should have been disposed with exception == false
+		assertTrue(transport.getDisposed());
+		assertFalse(transport.getException());
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testSomethingToSend() throws Exception {
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		TestSimplexTransportWriter transport = new TestSimplexTransportWriter(
+				out, MIN_CONNECTION_LENGTH, true);
+		ConnectionContext ctx = new ConnectionContext(contactId, transportId,
+				secret, 0L, true);
+		OutgoingSimplexConnection connection = new OutgoingSimplexConnection(db,
+				connRegistry, connFactory, protoFactory, ctx, transport);
+		final Ack ack = context.mock(Ack.class);
+		final BatchId batchId = new BatchId(TestUtils.getRandomId());
+		final RawBatch batch = context.mock(RawBatch.class);
+		final byte[] message = new byte[1234];
+		context.checking(new Expectations() {{
+			// No transports to send
+			oneOf(db).generateTransportUpdate(contactId);
+			will(returnValue(null));
+			// No subscriptions to send
+			oneOf(db).generateSubscriptionUpdate(contactId);
+			will(returnValue(null));
+			// One ack to send
+			oneOf(db).generateAck(with(contactId), with(any(int.class)));
+			will(returnValue(ack));
+			oneOf(ack).getBatchIds();
+			will(returnValue(Collections.singletonList(batchId)));
+			// No more acks
+			oneOf(db).generateAck(with(contactId), with(any(int.class)));
+			will(returnValue(null));
+			// One batch to send
+			oneOf(db).generateBatch(with(contactId), with(any(int.class)));
+			will(returnValue(batch));
+			oneOf(batch).getMessages();
+			will(returnValue(Collections.singletonList(message)));
+			// No more batches
+			oneOf(db).generateBatch(with(contactId), with(any(int.class)));
+			will(returnValue(null));
+		}});
+		connection.write();
+		// Something should have been written
+		int overhead = TAG_LENGTH + HEADER_LENGTH + MAC_LENGTH;
+		assertTrue(out.size() > overhead + UniqueId.LENGTH + message.length);
+		// The transport should have been disposed with exception == false
+		assertTrue(transport.getDisposed());
+		assertFalse(transport.getException());
+		context.assertIsSatisfied();
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/protocol/simplex/SimplexProtocolIntegrationTest.java b/briar-tests/src/net/sf/briar/protocol/simplex/SimplexProtocolIntegrationTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..302b37e3d0f9ef4d948fb6c56164b309a0c528f3
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/protocol/simplex/SimplexProtocolIntegrationTest.java
@@ -0,0 +1,223 @@
+package net.sf.briar.protocol.simplex;
+
+import static net.sf.briar.api.transport.TransportConstants.TAG_LENGTH;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Random;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.TestDatabaseModule;
+import net.sf.briar.TestUtils;
+import net.sf.briar.api.ContactId;
+import net.sf.briar.api.crypto.KeyManager;
+import net.sf.briar.api.db.DatabaseComponent;
+import net.sf.briar.api.db.event.DatabaseEvent;
+import net.sf.briar.api.db.event.DatabaseListener;
+import net.sf.briar.api.db.event.MessagesAddedEvent;
+import net.sf.briar.api.protocol.Message;
+import net.sf.briar.api.protocol.MessageFactory;
+import net.sf.briar.api.protocol.ProtocolReaderFactory;
+import net.sf.briar.api.protocol.ProtocolWriterFactory;
+import net.sf.briar.api.protocol.Transport;
+import net.sf.briar.api.protocol.TransportId;
+import net.sf.briar.api.protocol.TransportUpdate;
+import net.sf.briar.api.transport.ConnectionContext;
+import net.sf.briar.api.transport.ConnectionReaderFactory;
+import net.sf.briar.api.transport.ConnectionRecogniser;
+import net.sf.briar.api.transport.ConnectionRegistry;
+import net.sf.briar.api.transport.ConnectionWriterFactory;
+import net.sf.briar.api.transport.ContactTransport;
+import net.sf.briar.clock.ClockModule;
+import net.sf.briar.crypto.CryptoModule;
+import net.sf.briar.db.DatabaseModule;
+import net.sf.briar.lifecycle.LifecycleModule;
+import net.sf.briar.plugins.ImmediateExecutor;
+import net.sf.briar.protocol.ProtocolModule;
+import net.sf.briar.protocol.duplex.DuplexProtocolModule;
+import net.sf.briar.serial.SerialModule;
+import net.sf.briar.transport.TransportModule;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+
+public class SimplexProtocolIntegrationTest extends BriarTestCase {
+
+	private static final long CLOCK_DIFFERENCE = 60 * 1000L;
+	private static final long LATENCY = 60 * 1000L;
+
+	private final File testDir = TestUtils.getTestDirectory();
+	private final File aliceDir = new File(testDir, "alice");
+	private final File bobDir = new File(testDir, "bob");
+	private final TransportId transportId;
+	private final byte[] initialSecret;
+	private final long epoch;
+
+	private Injector alice, bob;
+
+	public SimplexProtocolIntegrationTest() throws Exception {
+		super();
+		transportId = new TransportId(TestUtils.getRandomId());
+		// Create matching secrets for Alice and Bob
+		initialSecret = new byte[32];
+		new Random().nextBytes(initialSecret);
+		long rotationPeriod = 2 * CLOCK_DIFFERENCE + LATENCY;
+		epoch = System.currentTimeMillis() - 2 * rotationPeriod;
+	}
+
+	@Before
+	public void setUp() {
+		testDir.mkdirs();
+		alice = createInjector(aliceDir);
+		bob = createInjector(bobDir);
+	}
+
+	private Injector createInjector(File dir) {
+		return Guice.createInjector(new ClockModule(), new CryptoModule(),
+				new DatabaseModule(), new LifecycleModule(),
+				new ProtocolModule(), new SerialModule(),
+				new TestDatabaseModule(dir), new SimplexProtocolModule(),
+				new TransportModule(), new DuplexProtocolModule());
+	}
+
+	@Test
+	public void testInjection() {
+		DatabaseComponent aliceDb = alice.getInstance(DatabaseComponent.class);
+		DatabaseComponent bobDb = bob.getInstance(DatabaseComponent.class);
+		assertFalse(aliceDb == bobDb);
+	}
+
+	@Test
+	public void testWriteAndRead() throws Exception {
+		read(write());
+	}
+
+	private byte[] write() throws Exception {
+		// Open Alice's database
+		DatabaseComponent db = alice.getInstance(DatabaseComponent.class);
+		db.open(false);
+		// Start Alice's key manager
+		KeyManager km = alice.getInstance(KeyManager.class);
+		km.start();
+		// Add Bob as a contact
+		ContactId contactId = db.addContact();
+		ContactTransport ct = new ContactTransport(contactId, transportId,
+				epoch, CLOCK_DIFFERENCE, LATENCY, true);
+		db.addContactTransport(ct);
+		km.contactTransportAdded(ct, initialSecret.clone());
+		// Send Bob a message
+		String subject = "Hello";
+		byte[] body = "Hi Bob!".getBytes("UTF-8");
+		MessageFactory messageFactory = alice.getInstance(MessageFactory.class);
+		Message message = messageFactory.createMessage(null, subject, body);
+		db.addLocalPrivateMessage(message, contactId);
+		// Create an outgoing simplex connection
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		ConnectionRegistry connRegistry =
+				alice.getInstance(ConnectionRegistry.class);
+		ConnectionWriterFactory connFactory =
+				alice.getInstance(ConnectionWriterFactory.class);
+		ProtocolWriterFactory protoFactory =
+				alice.getInstance(ProtocolWriterFactory.class);
+		TestSimplexTransportWriter transport = new TestSimplexTransportWriter(
+				out, Long.MAX_VALUE, false);
+		ConnectionContext ctx = km.getConnectionContext(contactId, transportId);
+		assertNotNull(ctx);
+		OutgoingSimplexConnection simplex = new OutgoingSimplexConnection(db,
+				connRegistry, connFactory, protoFactory, ctx, transport);
+		// Write whatever needs to be written
+		simplex.write();
+		assertTrue(transport.getDisposed());
+		assertFalse(transport.getException());
+		// Clean up
+		km.stop();
+		db.close();
+		// Return the contents of the simplex connection
+		return out.toByteArray();
+	}
+
+	private void read(byte[] b) throws Exception {
+		// Open Bob's database
+		DatabaseComponent db = bob.getInstance(DatabaseComponent.class);
+		db.open(false);
+		// Start Bob's key manager
+		KeyManager km = bob.getInstance(KeyManager.class);
+		km.start();
+		// Add Alice as a contact
+		ContactId contactId = db.addContact();
+		ContactTransport ct = new ContactTransport(contactId, transportId,
+				epoch, CLOCK_DIFFERENCE, LATENCY, false);
+		db.addContactTransport(ct);
+		km.contactTransportAdded(ct, initialSecret.clone());
+		// Set up a database listener
+		MessageListener listener = new MessageListener();
+		db.addListener(listener);
+		// Fake a transport update from Alice
+		TransportUpdate transportUpdate = new TransportUpdate() {
+
+			public Collection<Transport> getTransports() {
+				Transport t = new Transport(transportId);
+				return Collections.singletonList(t);
+			}
+
+			public long getTimestamp() {
+				return System.currentTimeMillis();
+			}
+		};
+		db.receiveTransportUpdate(contactId, transportUpdate);
+		// Create a connection recogniser and recognise the connection
+		ByteArrayInputStream in = new ByteArrayInputStream(b);
+		ConnectionRecogniser rec = bob.getInstance(ConnectionRecogniser.class);
+		byte[] tag = new byte[TAG_LENGTH];
+		int read = in.read(tag);
+		assertEquals(tag.length, read);
+		ConnectionContext ctx = rec.acceptConnection(transportId, tag);
+		assertNotNull(ctx);
+		// Create an incoming simplex connection
+		ConnectionRegistry connRegistry =
+				bob.getInstance(ConnectionRegistry.class);
+		ConnectionReaderFactory connFactory =
+				bob.getInstance(ConnectionReaderFactory.class);
+		ProtocolReaderFactory protoFactory =
+				bob.getInstance(ProtocolReaderFactory.class);
+		TestSimplexTransportReader transport =
+				new TestSimplexTransportReader(in);
+		IncomingSimplexConnection simplex = new IncomingSimplexConnection(
+				new ImmediateExecutor(), new ImmediateExecutor(), db,
+				connRegistry, connFactory, protoFactory, ctx, transport);
+		// No messages should have been added yet
+		assertFalse(listener.messagesAdded);
+		// Read whatever needs to be read
+		simplex.read();
+		assertTrue(transport.getDisposed());
+		assertFalse(transport.getException());
+		assertTrue(transport.getRecognised());
+		// The private message from Alice should have been added
+		assertTrue(listener.messagesAdded);
+		// Clean up
+		km.stop();
+		db.close();
+	}
+
+	@After
+	public void tearDown() {
+		TestUtils.deleteTestDirectory(testDir);
+	}
+
+	private static class MessageListener implements DatabaseListener {
+
+		private boolean messagesAdded = false;
+
+		public void eventOccurred(DatabaseEvent e) {
+			if(e instanceof MessagesAddedEvent)
+				messagesAdded = true;
+		}
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/protocol/simplex/TestSimplexTransportReader.java b/briar-tests/src/net/sf/briar/protocol/simplex/TestSimplexTransportReader.java
new file mode 100644
index 0000000000000000000000000000000000000000..1d85ed3ec57599ea7e3e49b273bf1e09313c4db3
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/protocol/simplex/TestSimplexTransportReader.java
@@ -0,0 +1,39 @@
+package net.sf.briar.protocol.simplex;
+
+import java.io.InputStream;
+
+import net.sf.briar.api.plugins.simplex.SimplexTransportReader;
+
+class TestSimplexTransportReader implements SimplexTransportReader {
+
+	private final InputStream in;
+
+	private boolean disposed = false, exception = false, recognised = false;
+
+	TestSimplexTransportReader(InputStream in) {
+		this.in = in;
+	}
+
+	public InputStream getInputStream() {
+		return in;
+	}
+
+	public void dispose(boolean exception, boolean recognised) {
+		assert !disposed;
+		disposed = true;
+		this.exception = exception;
+		this.recognised = recognised;
+	}
+
+	boolean getDisposed() {
+		return disposed;
+	}
+
+	boolean getException() {
+		return exception;
+	}
+
+	boolean getRecognised() {
+		return recognised;
+	}
+}
\ No newline at end of file
diff --git a/briar-tests/src/net/sf/briar/protocol/simplex/TestSimplexTransportWriter.java b/briar-tests/src/net/sf/briar/protocol/simplex/TestSimplexTransportWriter.java
new file mode 100644
index 0000000000000000000000000000000000000000..7a1524cb5761d6e43899a4f1e6860183dd4451c2
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/protocol/simplex/TestSimplexTransportWriter.java
@@ -0,0 +1,48 @@
+package net.sf.briar.protocol.simplex;
+
+import java.io.ByteArrayOutputStream;
+import java.io.OutputStream;
+
+import net.sf.briar.api.plugins.simplex.SimplexTransportWriter;
+
+class TestSimplexTransportWriter implements SimplexTransportWriter {
+
+	private final ByteArrayOutputStream out;
+	private final long capacity;
+	private final boolean flush;
+
+	private boolean disposed = false, exception = false;
+
+	TestSimplexTransportWriter(ByteArrayOutputStream out, long capacity,
+			boolean flush) {
+		this.out = out;
+		this.capacity = capacity;
+		this.flush = flush;
+	}
+
+	public long getCapacity() {
+		return capacity;
+	}
+
+	public OutputStream getOutputStream() {
+		return out;
+	}
+
+	public boolean shouldFlush() {
+		return flush;
+	}
+
+	public void dispose(boolean exception) {
+		assert !disposed;
+		disposed = true;
+		this.exception = exception;
+	}
+
+	boolean getDisposed() {
+		return disposed;
+	}
+
+	boolean getException() {
+		return exception;
+	}
+}
\ No newline at end of file
diff --git a/briar-tests/src/net/sf/briar/serial/ReaderImplTest.java b/briar-tests/src/net/sf/briar/serial/ReaderImplTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..a79cdb56b8661484bea66ae1f1e8664876cd7513
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/serial/ReaderImplTest.java
@@ -0,0 +1,556 @@
+package net.sf.briar.serial;
+
+import static org.junit.Assert.assertArrayEquals;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.api.Bytes;
+import net.sf.briar.api.FormatException;
+import net.sf.briar.api.serial.Consumer;
+import net.sf.briar.api.serial.StructReader;
+import net.sf.briar.api.serial.Reader;
+import net.sf.briar.util.StringUtils;
+
+import org.junit.Test;
+
+public class ReaderImplTest extends BriarTestCase {
+
+	private ByteArrayInputStream in = null;
+	private ReaderImpl r = null;
+
+	@Test
+	public void testReadBoolean() throws Exception {
+		setContents("FFFE");
+		assertFalse(r.readBoolean());
+		assertTrue(r.readBoolean());
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadInt8() throws Exception {
+		setContents("FD00" + "FDFF" + "FD7F" + "FD80");
+		assertEquals((byte) 0, r.readInt8());
+		assertEquals((byte) -1, r.readInt8());
+		assertEquals(Byte.MAX_VALUE, r.readInt8());
+		assertEquals(Byte.MIN_VALUE, r.readInt8());
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadInt16() throws Exception {
+		setContents("FC0000" + "FCFFFF" + "FC7FFF" + "FC8000");
+		assertEquals((short) 0, r.readInt16());
+		assertEquals((short) -1, r.readInt16());
+		assertEquals(Short.MAX_VALUE, r.readInt16());
+		assertEquals(Short.MIN_VALUE, r.readInt16());
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadInt32() throws Exception {
+		setContents("FB00000000" + "FBFFFFFFFF" + "FB7FFFFFFF" + "FB80000000");
+		assertEquals(0, r.readInt32());
+		assertEquals(-1, r.readInt32());
+		assertEquals(Integer.MAX_VALUE, r.readInt32());
+		assertEquals(Integer.MIN_VALUE, r.readInt32());
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadInt64() throws Exception {
+		setContents("FA0000000000000000" + "FAFFFFFFFFFFFFFFFF"
+				+ "FA7FFFFFFFFFFFFFFF" + "FA8000000000000000");
+		assertEquals(0L, r.readInt64());
+		assertEquals(-1L, r.readInt64());
+		assertEquals(Long.MAX_VALUE, r.readInt64());
+		assertEquals(Long.MIN_VALUE, r.readInt64());
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadIntAny() throws Exception {
+		setContents("00" + "7F" + "FD80" + "FDFF" + "FC0080" + "FC7FFF"
+				+ "FB00008000" + "FB7FFFFFFF" + "FA0000000080000000");
+		assertEquals(0L, r.readIntAny());
+		assertEquals(127L, r.readIntAny());
+		assertEquals(-128L, r.readIntAny());
+		assertEquals(-1L, r.readIntAny());
+		assertEquals(128L, r.readIntAny());
+		assertEquals(32767L, r.readIntAny());
+		assertEquals(32768L, r.readIntAny());
+		assertEquals(2147483647L, r.readIntAny());
+		assertEquals(2147483648L, r.readIntAny());
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadFloat32() throws Exception {
+		// http://babbage.cs.qc.edu/IEEE-754/Decimal.html
+		// http://steve.hollasch.net/cgindex/coding/ieeefloat.html
+		setContents("F900000000" + "F93F800000" + "F940000000" + "F9BF800000"
+				+ "F980000000" + "F9FF800000" + "F97F800000" + "F97FC00000");
+		assertEquals(0F, r.readFloat32());
+		assertEquals(1F, r.readFloat32());
+		assertEquals(2F, r.readFloat32());
+		assertEquals(-1F, r.readFloat32());
+		assertEquals(-0F, r.readFloat32());
+		assertEquals(Float.NEGATIVE_INFINITY, r.readFloat32());
+		assertEquals(Float.POSITIVE_INFINITY, r.readFloat32());
+		assertTrue(Float.isNaN(r.readFloat32()));
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadFloat64() throws Exception {
+		setContents("F80000000000000000" + "F83FF0000000000000"
+				+ "F84000000000000000" + "F8BFF0000000000000"
+				+ "F88000000000000000" + "F8FFF0000000000000"
+				+ "F87FF0000000000000" + "F87FF8000000000000");
+		assertEquals(0.0, r.readFloat64());
+		assertEquals(1.0, r.readFloat64());
+		assertEquals(2.0, r.readFloat64());
+		assertEquals(-1.0, r.readFloat64());
+		assertEquals(-0.0, r.readFloat64());
+		assertEquals(Double.NEGATIVE_INFINITY, r.readFloat64());
+		assertEquals(Double.POSITIVE_INFINITY, r.readFloat64());
+		assertTrue(Double.isNaN(r.readFloat64()));
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadString() throws Exception {
+		setContents("F703666F6F" + "83666F6F" + "F700" + "80");
+		assertEquals("foo", r.readString());
+		assertEquals("foo", r.readString());
+		assertEquals("", r.readString());
+		assertEquals("", r.readString());
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadStringMaxLength() throws Exception {
+		setContents("83666F6F" + "83666F6F");
+		assertEquals("foo", r.readString(3));
+		try {
+			r.readString(2);
+			fail();
+		} catch(FormatException expected) {}
+	}
+
+	@Test
+	public void testReadBytes() throws Exception {
+		setContents("F603010203" + "93010203" + "F600" + "90");
+		assertArrayEquals(new byte[] {1, 2, 3}, r.readBytes());
+		assertArrayEquals(new byte[] {1, 2, 3}, r.readBytes());
+		assertArrayEquals(new byte[] {}, r.readBytes());
+		assertArrayEquals(new byte[] {}, r.readBytes());
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadBytesMaxLength() throws Exception {
+		setContents("93010203" + "93010203");
+		assertArrayEquals(new byte[] {1, 2, 3}, r.readBytes(3));
+		try {
+			r.readBytes(2);
+			fail();
+		} catch(FormatException expected) {}
+	}
+
+	@Test
+	public void testReadShortList() throws Exception {
+		setContents("A" + "3" + "01" + "83666F6F" + "FC0080");
+		List<Object> l = r.readList(Object.class);
+		assertNotNull(l);
+		assertEquals(3, l.size());
+		assertEquals((byte) 1, l.get(0));
+		assertEquals("foo", l.get(1));
+		assertEquals((short) 128, l.get(2));
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadList() throws Exception {
+		setContents("F5" + "01" + "83666F6F" + "FC0080" + "F3");
+		List<Object> l = r.readList(Object.class);
+		assertNotNull(l);
+		assertEquals(3, l.size());
+		assertEquals((byte) 1, l.get(0));
+		assertEquals("foo", l.get(1));
+		assertEquals((short) 128, l.get(2));
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadListTypeSafe() throws Exception {
+		setContents("A" + "3" + "01" + "02" + "03");
+		List<Byte> l = r.readList(Byte.class);
+		assertNotNull(l);
+		assertEquals(3, l.size());
+		assertEquals(Byte.valueOf((byte) 1), l.get(0));
+		assertEquals(Byte.valueOf((byte) 2), l.get(1));
+		assertEquals(Byte.valueOf((byte) 3), l.get(2));
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadListTypeSafeThrowsFormatException() throws Exception {
+		setContents("A" + "3" + "01" + "83666F6F" + "03");
+		// Trying to read a mixed list as a list of bytes should throw a
+		// FormatException
+		try {
+			r.readList(Byte.class);
+			fail();
+		} catch(FormatException expected) {}
+	}
+
+	@Test
+	public void testReadShortMap() throws Exception {
+		setContents("B" + "2" + "83666F6F" + "7B" + "90" + "F2");
+		Map<Object, Object> m = r.readMap(Object.class, Object.class);
+		assertNotNull(m);
+		assertEquals(2, m.size());
+		assertEquals((byte) 123, m.get("foo"));
+		Bytes b = new Bytes(new byte[] {});
+		assertTrue(m.containsKey(b));
+		assertNull(m.get(b));
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadMap() throws Exception {
+		setContents("F4" + "83666F6F" + "7B" + "90" + "F2" + "F3");
+		Map<Object, Object> m = r.readMap(Object.class, Object.class);
+		assertNotNull(m);
+		assertEquals(2, m.size());
+		assertEquals((byte) 123, m.get("foo"));
+		Bytes b = new Bytes(new byte[] {});
+		assertTrue(m.containsKey(b));
+		assertNull(m.get(b));
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadMapTypeSafe() throws Exception {
+		setContents("B" + "2" + "83666F6F" + "7B" + "80" + "F2");
+		Map<String, Byte> m = r.readMap(String.class, Byte.class);
+		assertNotNull(m);
+		assertEquals(2, m.size());
+		assertEquals(Byte.valueOf((byte) 123), m.get("foo"));
+		assertTrue(m.containsKey(""));
+		assertNull(m.get(""));
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testMapKeysMustBeUnique() throws Exception {
+		setContents("B" + "2" + "83666F6F" + "01" + "83626172" + "02"
+				+ "B" + "2" + "83666F6F" + "01" + "83666F6F" + "02");
+		// The first map has unique keys
+		Map<String, Byte> m = r.readMap(String.class, Byte.class);
+		assertNotNull(m);
+		assertEquals(2, m.size());
+		assertEquals(Byte.valueOf((byte) 1), m.get("foo"));
+		assertEquals(Byte.valueOf((byte) 2), m.get("bar"));
+		// The second map has a duplicate key
+		try {
+			r.readMap(String.class, Byte.class);
+			fail();
+		} catch(FormatException expected) {}
+	}
+
+	@Test
+	public void testReadDelimitedList() throws Exception {
+		setContents("F5" + "01" + "83666F6F" + "FC0080" + "F3");
+		List<Object> l = r.readList(Object.class);
+		assertNotNull(l);
+		assertEquals(3, l.size());
+		assertEquals((byte) 1, l.get(0));
+		assertEquals("foo", l.get(1));
+		assertEquals((short) 128, l.get(2));
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadDelimitedListElements() throws Exception {
+		setContents("F5" + "01" + "83666F6F" + "FC0080" + "F3");
+		assertTrue(r.hasListStart());
+		r.readListStart();
+		assertFalse(r.hasListEnd());
+		assertEquals((byte) 1, r.readIntAny());
+		assertFalse(r.hasListEnd());
+		assertEquals("foo", r.readString());
+		assertFalse(r.hasListEnd());
+		assertEquals((short) 128, r.readIntAny());
+		assertTrue(r.hasListEnd());
+		r.readListEnd();
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadDelimitedListTypeSafe() throws Exception {
+		setContents("F5" + "01" + "02" + "03" + "F3");
+		List<Byte> l = r.readList(Byte.class);
+		assertNotNull(l);
+		assertEquals(3, l.size());
+		assertEquals(Byte.valueOf((byte) 1), l.get(0));
+		assertEquals(Byte.valueOf((byte) 2), l.get(1));
+		assertEquals(Byte.valueOf((byte) 3), l.get(2));
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadDelimitedMap() throws Exception {
+		setContents("F4" + "83666F6F" + "7B" + "90" + "F2" + "F3");
+		Map<Object, Object> m = r.readMap(Object.class, Object.class);
+		assertNotNull(m);
+		assertEquals(2, m.size());
+		assertEquals((byte) 123, m.get("foo"));
+		Bytes b = new Bytes(new byte[] {});
+		assertTrue(m.containsKey(b));
+		assertNull(m.get(b));
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadDelimitedMapEntries() throws Exception {
+		setContents("F4" + "83666F6F" + "7B" + "90" + "F2" + "F3");
+		assertTrue(r.hasMapStart());
+		r.readMapStart();
+		assertFalse(r.hasMapEnd());
+		assertEquals("foo", r.readString());
+		assertFalse(r.hasMapEnd());
+		assertEquals((byte) 123, r.readIntAny());
+		assertFalse(r.hasMapEnd());
+		assertArrayEquals(new byte[] {}, r.readBytes());
+		assertFalse(r.hasMapEnd());
+		assertTrue(r.hasNull());
+		r.readNull();
+		assertTrue(r.hasMapEnd());
+		r.readMapEnd();
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadDelimitedMapTypeSafe() throws Exception {
+		setContents("F4" + "83666F6F" + "7B" + "80" + "F2" + "F3");
+		Map<String, Byte> m = r.readMap(String.class, Byte.class);
+		assertNotNull(m);
+		assertEquals(2, m.size());
+		assertEquals(Byte.valueOf((byte) 123), m.get("foo"));
+		assertTrue(m.containsKey(""));
+		assertNull(m.get(""));
+		assertTrue(r.eof());
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	public void testReadNestedMapsAndLists() throws Exception {
+		setContents("B" + "1" + "B" + "1" + "83666F6F" + "7B"
+				+ "A" + "1" + "01");
+		Map<Object, Object> m = r.readMap(Object.class, Object.class);
+		assertNotNull(m);
+		assertEquals(1, m.size());
+		Entry<Object, Object> e = m.entrySet().iterator().next();
+		Map<Object, Object> m1 = (Map<Object, Object>) e.getKey();
+		assertNotNull(m1);
+		assertEquals(1, m1.size());
+		assertEquals((byte) 123, m1.get("foo"));
+		List<Object> l = (List<Object>) e.getValue();
+		assertNotNull(l);
+		assertEquals(1, l.size());
+		assertEquals((byte) 1, l.get(0));
+		assertTrue(r.eof());
+	}
+
+	@Test
+	public void testReadStruct() throws Exception {
+		setContents("C0" + "83666F6F" + "F1" + "FF" + "83666F6F");
+		// Add readers for two structs
+		r.addStructReader(0, new StructReader<Foo>() {
+			public Foo readStruct(Reader r) throws IOException {
+				r.readStructId(0);
+				return new Foo(r.readString());
+			}
+		});
+		r.addStructReader(255, new StructReader<Bar>() {
+			public Bar readStruct(Reader r) throws IOException {
+				r.readStructId(255);
+				return new Bar(r.readString());
+			}
+		});
+		// Test both ID formats, short and long
+		assertTrue(r.hasStruct(0));
+		assertEquals("foo", r.readStruct(0, Foo.class).s);
+		assertTrue(r.hasStruct(255));
+		assertEquals("foo", r.readStruct(255, Bar.class).s);
+	}
+
+	@Test
+	public void testReadStructWithConsumer() throws Exception {
+		setContents("C0" + "83666F6F" + "F1" + "FF" + "83666F6F");
+		// Add readers for two structs
+		r.addStructReader(0, new StructReader<Foo>() {
+			public Foo readStruct(Reader r) throws IOException {
+				r.readStructId(0);
+				return new Foo(r.readString());
+			}
+		});
+		r.addStructReader(255, new StructReader<Bar>() {
+			public Bar readStruct(Reader r) throws IOException {
+				r.readStructId(255);
+				return new Bar(r.readString());
+			}
+		});
+		// Add a consumer
+		final ByteArrayOutputStream out = new ByteArrayOutputStream();
+		r.addConsumer(new Consumer() {
+
+			public void write(byte b) throws IOException {
+				out.write(b);
+			}
+
+			public void write(byte[] b, int off, int len) throws IOException {
+				out.write(b, off, len);
+			}
+		});
+		// Test both ID formats, short and long
+		assertTrue(r.hasStruct(0));
+		assertEquals("foo", r.readStruct(0, Foo.class).s);
+		assertTrue(r.hasStruct(255));
+		assertEquals("foo", r.readStruct(255, Bar.class).s);
+		// Check that everything was passed to the consumer
+		assertEquals("C0" + "83666F6F" + "F1" + "FF" + "83666F6F",
+				StringUtils.toHexString(out.toByteArray()));
+	}
+
+	@Test
+	public void testUnknownStructIdThrowsFormatException() throws Exception {
+		setContents("C0" + "83666F6F");
+		assertTrue(r.hasStruct(0));
+		// No reader has been added for struct ID 0
+		try {
+			r.readStruct(0, Foo.class);
+			fail();
+		} catch(FormatException expected) {}
+	}
+
+	@Test
+	public void testWrongClassThrowsFormatException() throws Exception {
+		setContents("C0" + "83666F6F");
+		// Add a reader for struct ID 0, class Foo
+		r.addStructReader(0, new StructReader<Foo>() {
+			public Foo readStruct(Reader r) throws IOException {
+				r.readStructId(0);
+				return new Foo(r.readString());
+			}
+		});
+		assertTrue(r.hasStruct(0));
+		// Trying to read the struct as class Bar should throw a FormatException
+		try {
+			r.readStruct(0, Bar.class);
+			fail();
+		} catch(FormatException expected) {}
+	}
+
+	@Test
+	public void testReadListUsingStructReader() throws Exception {
+		setContents("A" + "1" + "C0" + "83666F6F");
+		// Add a reader for a struct
+		r.addStructReader(0, new StructReader<Foo>() {
+			public Foo readStruct(Reader r) throws IOException {
+				r.readStructId(0);
+				return new Foo(r.readString());
+			}
+		});
+		// Check that the reader is used for lists
+		List<Foo> l = r.readList(Foo.class);
+		assertEquals(1, l.size());
+		assertEquals("foo", l.get(0).s);
+	}
+
+	@Test
+	public void testReadMapUsingStructReader() throws Exception {
+		setContents("B" + "1" + "C0" + "83666F6F" + "C1" + "83626172");
+		// Add readers for two structs
+		r.addStructReader(0, new StructReader<Foo>() {
+			public Foo readStruct(Reader r) throws IOException {
+				r.readStructId(0);
+				return new Foo(r.readString());
+			}
+		});
+		r.addStructReader(1, new StructReader<Bar>() {
+			public Bar readStruct(Reader r) throws IOException {
+				r.readStructId(1);
+				return new Bar(r.readString());
+			}
+		});
+		// Check that the readers are used for maps
+		Map<Foo, Bar> m = r.readMap(Foo.class, Bar.class);
+		assertEquals(1, m.size());
+		Entry<Foo, Bar> e = m.entrySet().iterator().next();
+		assertEquals("foo", e.getKey().s);
+		assertEquals("bar", e.getValue().s);
+	}
+
+	@Test
+	public void testMaxLengthAppliesInsideMap() throws Exception {
+		setContents("B" + "1" + "83666F6F" + "93010203");
+		r.setMaxStringLength(3);
+		r.setMaxBytesLength(3);
+		Map<String, Bytes> m = r.readMap(String.class, Bytes.class);
+		String key = "foo";
+		Bytes value = new Bytes(new byte[] {1, 2, 3});
+		assertEquals(Collections.singletonMap(key, value), m);
+		// The max string length should be applied inside the map
+		setContents("B" + "1" + "83666F6F" + "93010203");
+		r.setMaxStringLength(2);
+		try {
+			r.readMap(String.class, Bytes.class);
+			fail();
+		} catch(FormatException expected) {}
+		// The max bytes length should be applied inside the map
+		setContents("B" + "1" + "83666F6F" + "93010203");
+		r.setMaxBytesLength(2);
+		try {
+			r.readMap(String.class, Bytes.class);
+			fail();
+		} catch(FormatException expected) {}
+	}
+
+	@Test
+	public void testReadEmptyInput() throws Exception {
+		setContents("");
+		assertTrue(r.eof());
+	}
+
+	private void setContents(String hex) {
+		in = new ByteArrayInputStream(StringUtils.fromHexString(hex));
+		r = new ReaderImpl(in);
+	}
+
+	private static class Foo {
+
+		private final String s;
+
+		private Foo(String s) {
+			this.s = s;
+		}
+	}
+
+	private static class Bar {
+
+		private final String s;
+
+		private Bar(String s) {
+			this.s = s;
+		}
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/serial/WriterImplTest.java b/briar-tests/src/net/sf/briar/serial/WriterImplTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..7c9112e9d0fedb68072371a369755f2120266654
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/serial/WriterImplTest.java
@@ -0,0 +1,291 @@
+package net.sf.briar.serial;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.util.StringUtils;
+
+import org.junit.Before;
+import org.junit.Test;
+
+public class WriterImplTest extends BriarTestCase {
+
+	private ByteArrayOutputStream out = null;
+	private WriterImpl w = null;
+
+	@Before
+	public void setUp() {
+		out = new ByteArrayOutputStream();
+		w = new WriterImpl(out);
+	}
+
+	@Test
+	public void testWriteBoolean() throws IOException {
+		w.writeBoolean(true);
+		w.writeBoolean(false);
+		// TRUE tag, FALSE tag
+		checkContents("FE" + "FF");
+	}
+
+	@Test
+	public void testWriteUint7() throws IOException {
+		w.writeUint7((byte) 0);
+		w.writeUint7(Byte.MAX_VALUE);
+		// 0, 127
+		checkContents("00" + "7F");
+	}
+
+	@Test
+	public void testWriteInt8() throws IOException {
+		w.writeInt8((byte) 0);
+		w.writeInt8((byte) -1);
+		w.writeInt8(Byte.MIN_VALUE);
+		w.writeInt8(Byte.MAX_VALUE);
+		// INT8 tag, 0, INT8 tag, -1, INT8 tag, -128, INT8 tag, 127
+		checkContents("FD" + "00" + "FD" + "FF" + "FD" + "80" + "FD" + "7F");
+	}
+
+	@Test
+	public void testWriteInt16() throws IOException {
+		w.writeInt16((short) 0);
+		w.writeInt16((short) -1);
+		w.writeInt16(Short.MIN_VALUE);
+		w.writeInt16(Short.MAX_VALUE);
+		// INT16 tag, 0, INT16 tag, -1, INT16 tag, -32768, INT16 tag, 32767
+		checkContents("FC" + "0000" + "FC" + "FFFF" + "FC" + "8000"
+				+ "FC" + "7FFF");
+	}
+
+	@Test
+	public void testWriteInt32() throws IOException {
+		w.writeInt32(0);
+		w.writeInt32(-1);
+		w.writeInt32(Integer.MIN_VALUE);
+		w.writeInt32(Integer.MAX_VALUE);
+		// INT32 tag, 0, INT32 tag, -1, etc
+		checkContents("FB" + "00000000" + "FB" + "FFFFFFFF" + "FB" + "80000000"
+				+ "FB" + "7FFFFFFF");
+	}
+
+	@Test
+	public void testWriteInt64() throws IOException {
+		w.writeInt64(0L);
+		w.writeInt64(-1L);
+		w.writeInt64(Long.MIN_VALUE);
+		w.writeInt64(Long.MAX_VALUE);
+		// INT64 tag, 0, INT64 tag, -1, etc
+		checkContents("FA" + "0000000000000000" + "FA" + "FFFFFFFFFFFFFFFF"
+				+ "FA" + "8000000000000000" + "FA" + "7FFFFFFFFFFFFFFF");
+	}
+
+	@Test
+	public void testWriteIntAny() throws IOException {
+		w.writeIntAny(0); // uint7
+		w.writeIntAny(-1); // int8
+		w.writeIntAny(Byte.MAX_VALUE); // uint7
+		w.writeIntAny(Byte.MAX_VALUE + 1); // int16
+		w.writeIntAny(Short.MAX_VALUE); // int16
+		w.writeIntAny(Short.MAX_VALUE + 1); // int32
+		w.writeIntAny(Integer.MAX_VALUE); // int32
+		w.writeIntAny(Integer.MAX_VALUE + 1L); // int64
+		checkContents("00" + "FDFF" + "7F" + "FC0080" + "FC7FFF"
+				+ "FB00008000" + "FB7FFFFFFF" + "FA0000000080000000");
+	}
+
+	@Test
+	public void testWriteFloat32() throws IOException {
+		// http://babbage.cs.qc.edu/IEEE-754/Decimal.html
+		// 1 bit for sign, 8 for exponent, 23 for significand
+		w.writeFloat32(0F); // 0 0 0 -> 0x00000000
+		w.writeFloat32(1F); // 0 127 1 -> 0x3F800000
+		w.writeFloat32(2F); // 0 128 1 -> 0x40000000
+		w.writeFloat32(-1F); // 1 127 1 -> 0xBF800000
+		w.writeFloat32(-0F); // 1 0 0 -> 0x80000000
+		// http://steve.hollasch.net/cgindex/coding/ieeefloat.html
+		w.writeFloat32(Float.NEGATIVE_INFINITY); // 1 255 0 -> 0xFF800000
+		w.writeFloat32(Float.POSITIVE_INFINITY); // 0 255 0 -> 0x7F800000
+		w.writeFloat32(Float.NaN); // 0 255 1 -> 0x7FC00000
+		checkContents("F9" + "00000000" + "F9" + "3F800000" + "F9" + "40000000"
+				+ "F9" + "BF800000" + "F9" + "80000000" + "F9" + "FF800000"
+				+ "F9" + "7F800000" + "F9" + "7FC00000");
+	}
+
+	@Test
+	public void testWriteFloat64() throws IOException {
+		// 1 bit for sign, 11 for exponent, 52 for significand
+		w.writeFloat64(0.0); // 0 0 0 -> 0x0000000000000000
+		w.writeFloat64(1.0); // 0 1023 1 -> 0x3FF0000000000000
+		w.writeFloat64(2.0); // 0 1024 1 -> 0x4000000000000000
+		w.writeFloat64(-1.0); // 1 1023 1 -> 0xBFF0000000000000
+		w.writeFloat64(-0.0); // 1 0 0 -> 0x8000000000000000
+		w.writeFloat64(Double.NEGATIVE_INFINITY); // 1 2047 0 -> 0xFFF00000...
+		w.writeFloat64(Double.POSITIVE_INFINITY); // 0 2047 0 -> 0x7FF00000...
+		w.writeFloat64(Double.NaN); // 0 2047 1 -> 0x7FF8000000000000
+		checkContents("F8" + "0000000000000000" + "F8" + "3FF0000000000000"
+				+ "F8" + "4000000000000000" + "F8" + "BFF0000000000000"
+				+ "F8" + "8000000000000000" + "F8" + "FFF0000000000000"
+				+ "F8" + "7FF0000000000000" + "F8" + "7FF8000000000000");
+	}
+
+	@Test
+	public void testWriteShortString() throws IOException {
+		w.writeString("foo bar baz bam");
+		// SHORT_STRING tag, length 15, UTF-8 bytes
+		checkContents("8" + "F" + "666F6F206261722062617A2062616D");
+	}
+
+	@Test
+	public void testWriteString() throws IOException {
+		w.writeString("foo bar baz bam ");
+		// STRING tag, length 16 as uint7, UTF-8 bytes
+		checkContents("F7" + "10" + "666F6F206261722062617A2062616D20");
+	}
+
+	@Test
+	public void testWriteShortBytes() throws IOException {
+		w.writeBytes(new byte[] {
+				0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14
+		});
+		// SHORT_BYTES tag, length 15, bytes
+		checkContents("9" + "F" + "000102030405060708090A0B0C0D0E");
+	}
+
+	@Test
+	public void testWriteBytes() throws IOException {
+		w.writeBytes(new byte[] {
+				0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15
+		});
+		// BYTES tag, length 16 as uint7, bytes
+		checkContents("F6" + "10" + "000102030405060708090A0B0C0D0E0F");
+	}
+
+	@Test
+	public void testWriteShortList() throws IOException {
+		List<Object> l = new ArrayList<Object>();
+		for(int i = 0; i < 15; i++) l.add(i);
+		w.writeList(l);
+		// SHORT_LIST tag, length, elements as uint7
+		checkContents("A" + "F" + "000102030405060708090A0B0C0D0E");
+	}
+
+	@Test
+	public void testWriteList() throws IOException {
+		List<Object> l = new ArrayList<Object>();
+		for(int i = 0; i < 16; i++) l.add(i);
+		w.writeList(l);
+		// LIST tag, elements as uint7, END tag
+		checkContents("F5" + "000102030405060708090A0B0C0D0E0F" + "F3");
+	}
+
+	@Test
+	public void testListCanContainNull() throws IOException {
+		List<Object> l = new ArrayList<Object>();
+		l.add(1);
+		l.add(null);
+		l.add(2);
+		w.writeList(l);
+		// SHORT_LIST tag, length, 1 as uint7, null, 2 as uint7
+		checkContents("A" + "3" + "01" + "F2" + "02");
+	}
+
+	@Test
+	public void testWriteShortMap() throws IOException {
+		// Use LinkedHashMap to get predictable iteration order
+		Map<Object, Object> m = new LinkedHashMap<Object, Object>();
+		for(int i = 0; i < 15; i++) m.put(i, i + 1);
+		w.writeMap(m);
+		// SHORT_MAP tag, size, entries as uint7
+		checkContents("B" + "F" + "0001" + "0102" + "0203" + "0304" + "0405"
+				+ "0506" + "0607" + "0708" + "0809" + "090A" + "0A0B" + "0B0C"
+				+ "0C0D" + "0D0E" + "0E0F");
+	}
+
+	@Test
+	public void testWriteMap() throws IOException {
+		// Use LinkedHashMap to get predictable iteration order
+		Map<Object, Object> m = new LinkedHashMap<Object, Object>();
+		for(int i = 0; i < 16; i++) m.put(i, i + 1);
+		w.writeMap(m);
+		// MAP tag, entries as uint7, END tag
+		checkContents("F4" + "0001" + "0102" + "0203" + "0304" + "0405"
+				+ "0506" + "0607" + "0708" + "0809" + "090A" + "0A0B" + "0B0C"
+				+ "0C0D" + "0D0E" + "0E0F" + "0F10" + "F3");
+	}
+
+	@Test
+	public void testWriteDelimitedList() throws IOException {
+		w.writeListStart();
+		w.writeIntAny((byte) 1); // Written as uint7
+		w.writeString("foo"); // Written as short string
+		w.writeIntAny(128L); // Written as an int16
+		w.writeListEnd();
+		// LIST tag, 1 as uint7, "foo" as short string, 128 as int16,
+		// END tag
+		checkContents("F5" + "01" + "83666F6F" + "FC0080" + "F3");
+	}
+
+	@Test
+	public void testWriteDelimitedMap() throws IOException {
+		w.writeMapStart();
+		w.writeString("foo"); // Written as short string
+		w.writeIntAny(123); // Written as a uint7
+		w.writeBytes(new byte[] {}); // Written as short bytes
+		w.writeNull();
+		w.writeMapEnd();
+		// MAP tag, "foo" as short string, 123 as uint7,
+		// byte[] {} as short bytes, NULL tag, END tag
+		checkContents("F4" + "83666F6F" + "7B" + "90" + "F2" + "F3");
+	}
+
+	@Test
+	public void testWriteNestedMapsAndLists() throws IOException {
+		Map<Object, Object> m = new LinkedHashMap<Object, Object>();
+		m.put("foo", Integer.valueOf(123));
+		List<Object> l = new ArrayList<Object>();
+		l.add(Byte.valueOf((byte) 1));
+		Map<Object, Object> m1 = new LinkedHashMap<Object, Object>();
+		m1.put(m, l);
+		w.writeMap(m1);
+		// SHORT_MAP tag, length 1, SHORT_MAP tag, length 1,
+		// "foo" as short string, 123 as uint7, SHORT_LIST tag, length 1,
+		// 1 as uint7
+		checkContents("B" + "1" + "B" + "1" + "83666F6F" + "7B" + "A1" + "01");
+	}
+
+	@Test
+	public void testWriteNull() throws IOException {
+		w.writeNull();
+		checkContents("F2");
+	}
+
+	@Test
+	public void testWriteShortStructId() throws IOException {
+		w.writeStructId(0);
+		w.writeStructId(31);
+		// SHORT_STRUCT tag (3 bits), 0 (5 bits), SHORT_STRUCT tag (3 bits),
+		// 31 (5 bits)
+		checkContents("C0" + "DF");
+	}
+
+	@Test
+	public void testWriteStructId() throws IOException {
+		w.writeStructId(32);
+		w.writeStructId(255);
+		// STRUCT tag, 32 as uint8, STRUCT tag, 255 as uint8
+		checkContents("F1" + "20" + "F1" + "FF");
+	}
+
+	private void checkContents(String hex) throws IOException {
+		out.flush();
+		out.close();
+		byte[] expected = StringUtils.fromHexString(hex);
+		assertTrue(StringUtils.toHexString(out.toByteArray()),
+				Arrays.equals(expected, out.toByteArray()));
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/transport/ConnectionReaderImplTest.java b/briar-tests/src/net/sf/briar/transport/ConnectionReaderImplTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..4bec12fc63964be0720a2a6dfcab4fed5439c2ca
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/transport/ConnectionReaderImplTest.java
@@ -0,0 +1,107 @@
+package net.sf.briar.transport;
+
+import static net.sf.briar.api.transport.TransportConstants.HEADER_LENGTH;
+import static net.sf.briar.api.transport.TransportConstants.MAC_LENGTH;
+import net.sf.briar.BriarTestCase;
+
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.junit.Test;
+
+public class ConnectionReaderImplTest extends BriarTestCase {
+
+	private static final int FRAME_LENGTH = 1024;
+	private static final int MAX_PAYLOAD_LENGTH =
+			FRAME_LENGTH - HEADER_LENGTH - MAC_LENGTH;
+
+	@Test
+	public void testEmptyFramesAreSkipped() throws Exception {
+		Mockery context = new Mockery();
+		final FrameReader reader = context.mock(FrameReader.class);
+		context.checking(new Expectations() {{
+			oneOf(reader).readFrame(with(any(byte[].class)));
+			will(returnValue(0)); // Empty frame
+			oneOf(reader).readFrame(with(any(byte[].class)));
+			will(returnValue(2)); // Non-empty frame with two payload bytes
+			oneOf(reader).readFrame(with(any(byte[].class)));
+			will(returnValue(0)); // Empty frame
+			oneOf(reader).readFrame(with(any(byte[].class)));
+			will(returnValue(-1)); // No more frames
+		}});
+		ConnectionReaderImpl c = new ConnectionReaderImpl(reader, FRAME_LENGTH);
+		assertEquals(0, c.read()); // Skip the first empty frame, read a byte
+		assertEquals(0, c.read()); // Read another byte
+		assertEquals(-1, c.read()); // Skip the second empty frame, reach EOF
+		assertEquals(-1, c.read()); // Still at EOF
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testEmptyFramesAreSkippedWithBuffer() throws Exception {
+		Mockery context = new Mockery();
+		final FrameReader reader = context.mock(FrameReader.class);
+		context.checking(new Expectations() {{
+			oneOf(reader).readFrame(with(any(byte[].class)));
+			will(returnValue(0)); // Empty frame
+			oneOf(reader).readFrame(with(any(byte[].class)));
+			will(returnValue(2)); // Non-empty frame with two payload bytes
+			oneOf(reader).readFrame(with(any(byte[].class)));
+			will(returnValue(0)); // Empty frame
+			oneOf(reader).readFrame(with(any(byte[].class)));
+			will(returnValue(-1)); // No more frames
+		}});
+		ConnectionReaderImpl c = new ConnectionReaderImpl(reader, FRAME_LENGTH);
+		byte[] buf = new byte[MAX_PAYLOAD_LENGTH];
+		// Skip the first empty frame, read the two payload bytes
+		assertEquals(2, c.read(buf));
+		// Skip the second empty frame, reach EOF
+		assertEquals(-1, c.read(buf));
+		// Still at EOF
+		assertEquals(-1, c.read(buf));
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testMultipleReadsPerFrame() throws Exception {
+		Mockery context = new Mockery();
+		final FrameReader reader = context.mock(FrameReader.class);
+		context.checking(new Expectations() {{
+			oneOf(reader).readFrame(with(any(byte[].class)));
+			will(returnValue(MAX_PAYLOAD_LENGTH)); // Nice long frame
+			oneOf(reader).readFrame(with(any(byte[].class)));
+			will(returnValue(-1)); // No more frames
+		}});
+		ConnectionReaderImpl c = new ConnectionReaderImpl(reader, FRAME_LENGTH);
+		byte[] buf = new byte[MAX_PAYLOAD_LENGTH / 2];
+		// Read the first half of the payload
+		assertEquals(MAX_PAYLOAD_LENGTH / 2, c.read(buf));
+		// Read the second half of the payload
+		assertEquals(MAX_PAYLOAD_LENGTH / 2, c.read(buf));
+		// Reach EOF
+		assertEquals(-1, c.read(buf, 0, buf.length));
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testMultipleReadsPerFrameWithOffsets() throws Exception {
+		Mockery context = new Mockery();
+		final FrameReader reader = context.mock(FrameReader.class);
+		context.checking(new Expectations() {{
+			oneOf(reader).readFrame(with(any(byte[].class)));
+			will(returnValue(MAX_PAYLOAD_LENGTH)); // Nice long frame
+			oneOf(reader).readFrame(with(any(byte[].class)));
+			will(returnValue(-1)); // No more frames
+		}});
+		ConnectionReaderImpl c = new ConnectionReaderImpl(reader, FRAME_LENGTH);
+		byte[] buf = new byte[MAX_PAYLOAD_LENGTH];
+		// Read the first half of the payload
+		assertEquals(MAX_PAYLOAD_LENGTH / 2, c.read(buf, MAX_PAYLOAD_LENGTH / 2,
+				MAX_PAYLOAD_LENGTH / 2));
+		// Read the second half of the payload
+		assertEquals(MAX_PAYLOAD_LENGTH / 2, c.read(buf, 123,
+				MAX_PAYLOAD_LENGTH / 2));
+		// Reach EOF
+		assertEquals(-1, c.read(buf, 0, buf.length));
+		context.assertIsSatisfied();
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/transport/ConnectionRegistryImplTest.java b/briar-tests/src/net/sf/briar/transport/ConnectionRegistryImplTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..af5e853acc9181ed6c771eab4481c3e356c6307f
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/transport/ConnectionRegistryImplTest.java
@@ -0,0 +1,73 @@
+package net.sf.briar.transport;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.TestUtils;
+import net.sf.briar.api.ContactId;
+import net.sf.briar.api.protocol.TransportId;
+import net.sf.briar.api.transport.ConnectionRegistry;
+
+import org.junit.Test;
+
+public class ConnectionRegistryImplTest extends BriarTestCase {
+
+	private final ContactId contactId, contactId1;
+	private final TransportId transportId, transportId1;
+
+	public ConnectionRegistryImplTest() {
+		super();
+		contactId = new ContactId(1);
+		contactId1 = new ContactId(2);
+		transportId = new TransportId(TestUtils.getRandomId());
+		transportId1 = new TransportId(TestUtils.getRandomId());
+	}
+
+	@Test
+	public void testRegisterAndUnregister() {
+		ConnectionRegistry c = new ConnectionRegistryImpl();
+		// The registry should be empty
+		assertEquals(Collections.emptyList(),
+				c.getConnectedContacts(transportId));
+		assertEquals(Collections.emptyList(),
+				c.getConnectedContacts(transportId1));
+		// Check that a registered connection shows up
+		c.registerConnection(contactId, transportId);
+		assertEquals(Collections.singletonList(contactId),
+				c.getConnectedContacts(transportId));
+		assertEquals(Collections.emptyList(),
+				c.getConnectedContacts(transportId1));
+		// Register an identical connection - lookup should be unaffected
+		c.registerConnection(contactId, transportId);
+		assertEquals(Collections.singletonList(contactId),
+				c.getConnectedContacts(transportId));
+		assertEquals(Collections.emptyList(),
+				c.getConnectedContacts(transportId1));
+		// Unregister one of the connections - lookup should be unaffected
+		c.unregisterConnection(contactId, transportId);
+		assertEquals(Collections.singletonList(contactId),
+				c.getConnectedContacts(transportId));
+		assertEquals(Collections.emptyList(),
+				c.getConnectedContacts(transportId1));
+		// Unregister the other connection - lookup should be affected
+		c.unregisterConnection(contactId, transportId);
+		assertEquals(Collections.emptyList(),
+				c.getConnectedContacts(transportId));
+		assertEquals(Collections.emptyList(),
+				c.getConnectedContacts(transportId1));
+		// Try to unregister the connection again - exception should be thrown
+		try {
+			c.unregisterConnection(contactId, transportId);
+			fail();
+		} catch(IllegalArgumentException expected) {}
+		// Register both contacts with one transport, one contact with both
+		c.registerConnection(contactId, transportId);
+		c.registerConnection(contactId1, transportId);
+		c.registerConnection(contactId1, transportId1);
+		assertEquals(Arrays.asList(contactId, contactId1),
+				c.getConnectedContacts(transportId));
+		assertEquals(Collections.singletonList(contactId1),
+				c.getConnectedContacts(transportId1));
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/transport/ConnectionWindowTest.java b/briar-tests/src/net/sf/briar/transport/ConnectionWindowTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..808f6a8107b68d84b29bf13becb49fc399cf6895
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/transport/ConnectionWindowTest.java
@@ -0,0 +1,157 @@
+package net.sf.briar.transport;
+
+import static net.sf.briar.api.transport.TransportConstants.CONNECTION_WINDOW_SIZE;
+import static net.sf.briar.util.ByteUtils.MAX_32_BIT_UNSIGNED;
+import static org.junit.Assert.assertArrayEquals;
+
+import java.util.Collection;
+
+import net.sf.briar.BriarTestCase;
+
+import org.junit.Test;
+
+public class ConnectionWindowTest extends BriarTestCase {
+
+	@Test
+	public void testWindowSliding() {
+		ConnectionWindow w = new ConnectionWindow();
+		for(int i = 0; i < 100; i++) {
+			assertFalse(w.isSeen(i));
+			w.setSeen(i);
+			assertTrue(w.isSeen(i));
+		}
+	}
+
+	@Test
+	public void testWindowJumping() {
+		ConnectionWindow w = new ConnectionWindow();
+		for(int i = 0; i < 100; i += 13) {
+			assertFalse(w.isSeen(i));
+			w.setSeen(i);
+			assertTrue(w.isSeen(i));
+		}
+	}
+
+	@Test
+	public void testWindowUpperLimit() {
+		ConnectionWindow w = new ConnectionWindow();
+		// Centre is 0, highest value in window is 15
+		w.setSeen(15);
+		// Centre is 16, highest value in window is 31
+		w.setSeen(31);
+		try {
+			// Centre is 32, highest value in window is 47
+			w.setSeen(48);
+			fail();
+		} catch(IllegalArgumentException expected) {}
+		// Centre is max - 1, highest value in window is max
+		byte[] bitmap = new byte[CONNECTION_WINDOW_SIZE / 8];
+		w = new ConnectionWindow(MAX_32_BIT_UNSIGNED - 1, bitmap);
+		assertFalse(w.isSeen(MAX_32_BIT_UNSIGNED - 1));
+		assertFalse(w.isSeen(MAX_32_BIT_UNSIGNED));
+		// Values greater than max should never be allowed
+		try {
+			w.setSeen(MAX_32_BIT_UNSIGNED + 1);
+			fail();
+		} catch(IllegalArgumentException expected) {}
+		w.setSeen(MAX_32_BIT_UNSIGNED);
+		assertTrue(w.isSeen(MAX_32_BIT_UNSIGNED));
+		// Centre should have moved to max + 1
+		assertEquals(MAX_32_BIT_UNSIGNED + 1, w.getCentre());
+		// The bit corresponding to max should be set
+		byte[] expectedBitmap = new byte[CONNECTION_WINDOW_SIZE / 8];
+		expectedBitmap[expectedBitmap.length / 2 - 1] = 1; // 00000001
+		assertArrayEquals(expectedBitmap, w.getBitmap());
+		// Values greater than max should never be allowed even if centre > max
+		try {
+			w.setSeen(MAX_32_BIT_UNSIGNED + 1);
+			fail();
+		} catch(IllegalArgumentException expected) {}
+	}
+
+	@Test
+	public void testWindowLowerLimit() {
+		ConnectionWindow w = new ConnectionWindow();
+		// Centre is 0, negative values should never be allowed
+		try {
+			w.setSeen(-1);
+			fail();
+		} catch(IllegalArgumentException expected) {}
+		// Slide the window
+		w.setSeen(15);
+		// Centre is 16, lowest value in window is 0
+		w.setSeen(0);
+		// Slide the window
+		w.setSeen(16);
+		// Centre is 17, lowest value in window is 1
+		w.setSeen(1);
+		try {
+			w.setSeen(0);
+			fail();
+		} catch(IllegalArgumentException expected) {}
+		// Slide the window
+		w.setSeen(25);
+		// Centre is 26, lowest value in window is 10
+		w.setSeen(10);
+		try {
+			w.setSeen(9);
+			fail();
+		} catch(IllegalArgumentException expected) {}
+		// Centre should still be 26
+		assertEquals(26, w.getCentre());
+		// The bits corresponding to 10, 15, 16 and 25 should be set
+		byte[] expectedBitmap = new byte[CONNECTION_WINDOW_SIZE / 8];
+		expectedBitmap[0] = (byte) 134; // 10000110
+		expectedBitmap[1] = 1; // 00000001
+		assertArrayEquals(expectedBitmap, w.getBitmap());
+	}
+
+	@Test
+	public void testCannotSetSeenTwice() {
+		ConnectionWindow w = new ConnectionWindow();
+		w.setSeen(15);
+		try {
+			w.setSeen(15);
+			fail();
+		} catch(IllegalArgumentException expected) {}
+	}
+
+	@Test
+	public void testGetUnseenConnectionNumbers() {
+		ConnectionWindow w = new ConnectionWindow();
+		// Centre is 0; window should cover 0 to 15, inclusive, with none seen
+		Collection<Long> unseen = w.getUnseen();
+		assertEquals(16, unseen.size());
+		for(int i = 0; i < 16; i++) {
+			assertTrue(unseen.contains(Long.valueOf(i)));
+			assertFalse(w.isSeen(i));
+		}
+		w.setSeen(3);
+		w.setSeen(4);
+		// Centre is 5; window should cover 0 to 20, inclusive, with two seen
+		unseen = w.getUnseen();
+		assertEquals(19, unseen.size());
+		for(int i = 0; i < 21; i++) {
+			if(i == 3 || i == 4) {
+				assertFalse(unseen.contains(Long.valueOf(i)));
+				assertTrue(w.isSeen(i));
+			} else {
+				assertTrue(unseen.contains(Long.valueOf(i)));
+				assertFalse(w.isSeen(i));
+			}
+		}
+		w.setSeen(19);
+		// Centre is 20; window should cover 4 to 35, inclusive, with two seen
+		unseen = w.getUnseen();
+		assertEquals(30, unseen.size());
+		for(int i = 4; i < 36; i++) {
+			if(i == 4 || i == 19) {
+				assertFalse(unseen.contains(Long.valueOf(i)));
+				assertTrue(w.isSeen(i));
+			} else {
+				assertTrue(unseen.contains(Long.valueOf(i)));
+				assertFalse(w.isSeen(i));
+			}
+		}
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/transport/ConnectionWriterImplTest.java b/briar-tests/src/net/sf/briar/transport/ConnectionWriterImplTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..663700326da5b221ed7f66c5955f7e2b15f2b35c
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/transport/ConnectionWriterImplTest.java
@@ -0,0 +1,124 @@
+package net.sf.briar.transport;
+
+import static net.sf.briar.api.transport.TransportConstants.HEADER_LENGTH;
+import static net.sf.briar.api.transport.TransportConstants.MAC_LENGTH;
+import net.sf.briar.BriarTestCase;
+
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.junit.Test;
+
+
+public class ConnectionWriterImplTest extends BriarTestCase {
+
+	private static final int FRAME_LENGTH = 1024;
+	private static final int MAX_PAYLOAD_LENGTH =
+			FRAME_LENGTH - HEADER_LENGTH - MAC_LENGTH;
+
+	@Test
+	public void testCloseWithoutWritingWritesFinalFrame() throws Exception {
+		Mockery context = new Mockery();
+		final FrameWriter writer = context.mock(FrameWriter.class);
+		context.checking(new Expectations() {{
+			// Write an empty final frame
+			oneOf(writer).writeFrame(with(any(byte[].class)), with(0),
+					with(true));
+			// Flush the stream
+			oneOf(writer).flush();
+		}});
+		ConnectionWriterImpl c = new ConnectionWriterImpl(writer, FRAME_LENGTH);
+		c.close();
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testFlushWithoutBufferedDataWritesFrame() throws Exception {
+		Mockery context = new Mockery();
+		final FrameWriter writer = context.mock(FrameWriter.class);
+		ConnectionWriterImpl c = new ConnectionWriterImpl(writer, FRAME_LENGTH);
+		context.checking(new Expectations() {{
+			// Flush the stream
+			oneOf(writer).flush();
+		}});
+		c.flush();
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testFlushWithBufferedDataWritesFrameAndFlushes()
+			throws Exception {
+		Mockery context = new Mockery();
+		final FrameWriter writer = context.mock(FrameWriter.class);
+		ConnectionWriterImpl c = new ConnectionWriterImpl(writer, FRAME_LENGTH);
+		context.checking(new Expectations() {{
+			// Write a non-final frame with one payload byte
+			oneOf(writer).writeFrame(with(any(byte[].class)), with(1),
+					with(false));
+			// Flush the stream
+			oneOf(writer).flush();
+		}});
+		c.write(0);
+		c.flush();
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testSingleByteWritesWriteFullFrame() throws Exception {
+		Mockery context = new Mockery();
+		final FrameWriter writer = context.mock(FrameWriter.class);
+		ConnectionWriterImpl c = new ConnectionWriterImpl(writer, FRAME_LENGTH);
+		context.checking(new Expectations() {{
+			// Write a full non-final frame
+			oneOf(writer).writeFrame(with(any(byte[].class)),
+					with(MAX_PAYLOAD_LENGTH), with(false));
+		}});
+		for(int i = 0; i < MAX_PAYLOAD_LENGTH; i++) {
+			c.write(0);
+		}
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testMultiByteWritesWriteFullFrames() throws Exception {
+		Mockery context = new Mockery();
+		final FrameWriter writer = context.mock(FrameWriter.class);
+		ConnectionWriterImpl c = new ConnectionWriterImpl(writer, FRAME_LENGTH);
+		context.checking(new Expectations() {{
+			// Write two full non-final frames
+			exactly(2).of(writer).writeFrame(with(any(byte[].class)),
+					with(MAX_PAYLOAD_LENGTH), with(false));
+		}});
+		// Sanity check
+		assertEquals(0, MAX_PAYLOAD_LENGTH % 2);
+		// Write two full payloads using four multi-byte writes
+		byte[] b = new byte[MAX_PAYLOAD_LENGTH / 2];
+		c.write(b);
+		c.write(b);
+		c.write(b);
+		c.write(b);
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testLargeMultiByteWriteWritesFullFrames() throws Exception {
+		Mockery context = new Mockery();
+		final FrameWriter writer = context.mock(FrameWriter.class);
+		ConnectionWriterImpl c = new ConnectionWriterImpl(writer, FRAME_LENGTH);
+		context.checking(new Expectations() {{
+			// Write two full non-final frames
+			exactly(2).of(writer).writeFrame(with(any(byte[].class)),
+					with(MAX_PAYLOAD_LENGTH), with(false));
+			// Write a final frame with a one-byte payload
+			oneOf(writer).writeFrame(with(any(byte[].class)), with(1),
+					with(true));
+			// Flush the stream
+			oneOf(writer).flush();
+		}});
+		// Write two full payloads using one large multi-byte write
+		byte[] b = new byte[MAX_PAYLOAD_LENGTH * 2 + 1];
+		c.write(b);
+		// There should be one byte left in the buffer
+		c.close();
+		context.assertIsSatisfied();
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/transport/IncomingEncryptionLayerTest.java b/briar-tests/src/net/sf/briar/transport/IncomingEncryptionLayerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..7638bea2c66a4edaa610855e5882439ccdee2057
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/transport/IncomingEncryptionLayerTest.java
@@ -0,0 +1,183 @@
+package net.sf.briar.transport;
+
+import static javax.crypto.Cipher.ENCRYPT_MODE;
+import static net.sf.briar.api.transport.TransportConstants.AAD_LENGTH;
+import static net.sf.briar.api.transport.TransportConstants.HEADER_LENGTH;
+import static net.sf.briar.api.transport.TransportConstants.IV_LENGTH;
+import static net.sf.briar.api.transport.TransportConstants.MAC_LENGTH;
+
+import java.io.ByteArrayInputStream;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.api.FormatException;
+import net.sf.briar.api.crypto.AuthenticatedCipher;
+import net.sf.briar.api.crypto.CryptoComponent;
+import net.sf.briar.api.crypto.ErasableKey;
+import net.sf.briar.crypto.CryptoModule;
+
+import org.junit.Test;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+
+public class IncomingEncryptionLayerTest extends BriarTestCase {
+
+	// FIXME: This is an integration test, not a unit test
+
+	private static final int FRAME_LENGTH = 1024;
+	private static final int MAX_PAYLOAD_LENGTH =
+			FRAME_LENGTH - HEADER_LENGTH - MAC_LENGTH;
+
+	private final CryptoComponent crypto;
+	private final AuthenticatedCipher frameCipher;
+	private final ErasableKey frameKey;
+
+	public IncomingEncryptionLayerTest() {
+		super();
+		Injector i = Guice.createInjector(new CryptoModule());
+		crypto = i.getInstance(CryptoComponent.class);
+		frameCipher = crypto.getFrameCipher();
+		frameKey = crypto.generateTestKey();
+	}
+
+	@Test
+	public void testReadValidFrames() throws Exception {
+		// Generate two valid frames
+		byte[] frame = generateFrame(0L, FRAME_LENGTH, 123, false, false);
+		byte[] frame1 = generateFrame(1L, FRAME_LENGTH, 123, false, false);
+		// Concatenate the frames
+		byte[] valid = new byte[FRAME_LENGTH * 2];
+		System.arraycopy(frame, 0, valid, 0, FRAME_LENGTH);
+		System.arraycopy(frame1, 0, valid, FRAME_LENGTH, FRAME_LENGTH);
+		// Read the frames
+		ByteArrayInputStream in = new ByteArrayInputStream(valid);
+		IncomingEncryptionLayer i = new IncomingEncryptionLayer(in, frameCipher,
+				frameKey, FRAME_LENGTH);
+		byte[] buf = new byte[FRAME_LENGTH - MAC_LENGTH];
+		assertEquals(123, i.readFrame(buf));
+		assertEquals(123, i.readFrame(buf));
+	}
+
+	@Test
+	public void testTruncatedFrameThrowsException() throws Exception {
+		// Generate a valid frame
+		byte[] frame = generateFrame(0L, FRAME_LENGTH, 123, false, false);
+		// Chop off the last byte
+		byte[] truncated = new byte[FRAME_LENGTH - 1];
+		System.arraycopy(frame, 0, truncated, 0, FRAME_LENGTH - 1);
+		// Try to read the frame, which should fail due to truncation
+		ByteArrayInputStream in = new ByteArrayInputStream(truncated);
+		IncomingEncryptionLayer i = new IncomingEncryptionLayer(in, frameCipher,
+				frameKey, FRAME_LENGTH);
+		try {
+			i.readFrame(new byte[FRAME_LENGTH - MAC_LENGTH]);
+			fail();
+		} catch(FormatException expected) {}
+	}
+
+	@Test
+	public void testModifiedFrameThrowsException() throws Exception {
+		// Generate a valid frame
+		byte[] frame = generateFrame(0L, FRAME_LENGTH, 123, false, false);
+		// Modify a randomly chosen byte of the frame
+		frame[(int) (Math.random() * FRAME_LENGTH)] ^= 1;
+		// Try to read the frame, which should fail due to modification
+		ByteArrayInputStream in = new ByteArrayInputStream(frame);
+		IncomingEncryptionLayer i = new IncomingEncryptionLayer(in, frameCipher,
+				frameKey, FRAME_LENGTH);
+		try {
+			i.readFrame(new byte[FRAME_LENGTH - MAC_LENGTH]);
+			fail();
+		} catch(FormatException expected) {}
+	}
+
+	@Test
+	public void testShortNonFinalFrameThrowsException() throws Exception {
+		// Generate a short non-final frame
+		byte[] frame = generateFrame(0L, FRAME_LENGTH - 1, 123, false, false);
+		// Try to read the frame, which should fail due to invalid length
+		ByteArrayInputStream in = new ByteArrayInputStream(frame);
+		IncomingEncryptionLayer i = new IncomingEncryptionLayer(in, frameCipher,
+				frameKey, FRAME_LENGTH);
+		try {
+			i.readFrame(new byte[FRAME_LENGTH - MAC_LENGTH]);
+			fail();
+		} catch(FormatException expected) {}
+	}
+
+	@Test
+	public void testShortFinalFrameDoesNotThrowException() throws Exception {
+		// Generate a short final frame
+		byte[] frame = generateFrame(0L, FRAME_LENGTH - 1, 123, true, false);
+		// Read the frame
+		ByteArrayInputStream in = new ByteArrayInputStream(frame);
+		IncomingEncryptionLayer i = new IncomingEncryptionLayer(in, frameCipher,
+				frameKey, FRAME_LENGTH);
+		int length = i.readFrame(new byte[FRAME_LENGTH - MAC_LENGTH]);
+		assertEquals(123, length);
+	}
+
+	@Test
+	public void testInvalidPayloadLengthThrowsException() throws Exception {
+		// Generate a frame with an invalid payload length
+		byte[] frame = generateFrame(0L, FRAME_LENGTH, MAX_PAYLOAD_LENGTH + 1,
+				false, false);
+		// Try to read the frame, which should fail due to invalid length
+		ByteArrayInputStream in = new ByteArrayInputStream(frame);
+		IncomingEncryptionLayer i = new IncomingEncryptionLayer(in, frameCipher,
+				frameKey, FRAME_LENGTH);
+		try {
+			i.readFrame(new byte[FRAME_LENGTH - MAC_LENGTH]);
+			fail();
+		} catch(FormatException expected) {}
+	}
+
+	@Test
+	public void testNonZeroPaddingThrowsException() throws Exception {
+		// Generate a frame with bad padding
+		byte[] frame = generateFrame(0L, FRAME_LENGTH, 123, false, true);
+		// Try to read the frame, which should fail due to bad padding
+		ByteArrayInputStream in = new ByteArrayInputStream(frame);
+		IncomingEncryptionLayer i = new IncomingEncryptionLayer(in, frameCipher,
+				frameKey, FRAME_LENGTH);
+		try {
+			i.readFrame(new byte[FRAME_LENGTH - MAC_LENGTH]);
+			fail();
+		} catch(FormatException expected) {}
+	}
+
+	@Test
+	public void testCannotReadBeyondFinalFrame() throws Exception {
+		// Generate a valid final frame and another valid final frame after it
+		byte[] frame = generateFrame(0L, FRAME_LENGTH, MAX_PAYLOAD_LENGTH, true,
+				false);
+		byte[] frame1 = generateFrame(1L, FRAME_LENGTH, 123, true, false);
+		// Concatenate the frames
+		byte[] extraFrame = new byte[FRAME_LENGTH * 2];
+		System.arraycopy(frame, 0, extraFrame, 0, FRAME_LENGTH);
+		System.arraycopy(frame1, 0, extraFrame, FRAME_LENGTH, FRAME_LENGTH);
+		// Read the final frame, which should first read the tag
+		ByteArrayInputStream in = new ByteArrayInputStream(extraFrame);
+		IncomingEncryptionLayer i = new IncomingEncryptionLayer(in, frameCipher,
+				frameKey, FRAME_LENGTH);
+		byte[] buf = new byte[FRAME_LENGTH - MAC_LENGTH];
+		assertEquals(MAX_PAYLOAD_LENGTH, i.readFrame(buf));
+		// The frame after the final frame should not be read
+		assertEquals(-1, i.readFrame(buf));
+	}
+
+	private byte[] generateFrame(long frameNumber, int frameLength,
+			int payloadLength, boolean finalFrame, boolean badPadding)
+					throws Exception {
+		byte[] iv = new byte[IV_LENGTH], aad = new byte[AAD_LENGTH];
+		byte[] plaintext = new byte[frameLength - MAC_LENGTH];
+		byte[] ciphertext = new byte[frameLength];
+		FrameEncoder.encodeIv(iv, frameNumber);
+		FrameEncoder.encodeAad(aad, frameNumber, plaintext.length);
+		frameCipher.init(ENCRYPT_MODE, frameKey, iv, aad);
+		FrameEncoder.encodeHeader(plaintext, finalFrame, payloadLength);
+		if(badPadding) plaintext[HEADER_LENGTH + payloadLength] = 1;
+		frameCipher.doFinal(plaintext, 0, plaintext.length, ciphertext, 0);
+		return ciphertext;
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/transport/OutgoingEncryptionLayerTest.java b/briar-tests/src/net/sf/briar/transport/OutgoingEncryptionLayerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..475e5d9adb4d26237e533194994056c6ac86c504
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/transport/OutgoingEncryptionLayerTest.java
@@ -0,0 +1,159 @@
+package net.sf.briar.transport;
+
+import static javax.crypto.Cipher.ENCRYPT_MODE;
+import static net.sf.briar.api.transport.TransportConstants.AAD_LENGTH;
+import static net.sf.briar.api.transport.TransportConstants.HEADER_LENGTH;
+import static net.sf.briar.api.transport.TransportConstants.IV_LENGTH;
+import static net.sf.briar.api.transport.TransportConstants.MAC_LENGTH;
+import static net.sf.briar.api.transport.TransportConstants.TAG_LENGTH;
+
+import java.io.ByteArrayOutputStream;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.api.crypto.AuthenticatedCipher;
+import net.sf.briar.api.crypto.CryptoComponent;
+import net.sf.briar.api.crypto.ErasableKey;
+import net.sf.briar.crypto.CryptoModule;
+
+import org.junit.Test;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+
+public class OutgoingEncryptionLayerTest extends BriarTestCase {
+
+	// FIXME: This is an integration test, not a unit test
+
+	private static final int FRAME_LENGTH = 1024;
+	private static final int MAX_PAYLOAD_LENGTH =
+			FRAME_LENGTH - HEADER_LENGTH - MAC_LENGTH;
+
+	private final CryptoComponent crypto;
+	private final AuthenticatedCipher frameCipher;
+	private final byte[] tag;
+
+	public OutgoingEncryptionLayerTest() {
+		super();
+		Injector i = Guice.createInjector(new CryptoModule());
+		crypto = i.getInstance(CryptoComponent.class);
+		frameCipher = crypto.getFrameCipher();
+		tag = new byte[TAG_LENGTH];
+	}
+
+	@Test
+	public void testEncryption() throws Exception {
+		int payloadLength = 123;
+		byte[] iv = new byte[IV_LENGTH], aad = new byte[AAD_LENGTH];
+		byte[] plaintext = new byte[FRAME_LENGTH - MAC_LENGTH];
+		byte[] ciphertext = new byte[FRAME_LENGTH];
+		ErasableKey frameKey = crypto.generateTestKey();
+		// Calculate the expected ciphertext
+		FrameEncoder.encodeIv(iv, 0);
+		FrameEncoder.encodeAad(aad, 0, plaintext.length);
+		frameCipher.init(ENCRYPT_MODE, frameKey, iv, aad);
+		FrameEncoder.encodeHeader(plaintext, false, payloadLength);
+		frameCipher.doFinal(plaintext, 0, plaintext.length, ciphertext, 0);
+		// Check that the actual tag and ciphertext match what's expected
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		OutgoingEncryptionLayer o = new OutgoingEncryptionLayer(out,
+				10 * FRAME_LENGTH, frameCipher, frameKey, FRAME_LENGTH, tag);
+		o.writeFrame(new byte[FRAME_LENGTH - MAC_LENGTH], payloadLength, false);
+		byte[] actual = out.toByteArray();
+		assertEquals(TAG_LENGTH + FRAME_LENGTH, actual.length);
+		for(int i = 0; i < TAG_LENGTH; i++) assertEquals(tag[i], actual[i]);
+		for(int i = 0; i < FRAME_LENGTH; i++) {
+			assertEquals("" + i, ciphertext[i], actual[TAG_LENGTH + i]);
+		}
+	}
+
+	@Test
+	public void testInitiatorClosesConnectionWithoutWriting() throws Exception {
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		// Initiator's constructor
+		OutgoingEncryptionLayer o = new OutgoingEncryptionLayer(out,
+				10 * FRAME_LENGTH, frameCipher, crypto.generateTestKey(),
+				FRAME_LENGTH, tag);
+		// Write an empty final frame without having written any other frames
+		o.writeFrame(new byte[FRAME_LENGTH - MAC_LENGTH], 0, true);
+		// Nothing should be written to the output stream
+		assertEquals(0, out.size());
+	}
+
+	@Test
+	public void testResponderClosesConnectionWithoutWriting() throws Exception {
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		// Responder's constructor
+		OutgoingEncryptionLayer o = new OutgoingEncryptionLayer(out,
+				10 * FRAME_LENGTH, frameCipher, crypto.generateTestKey(),
+				FRAME_LENGTH);
+		// Write an empty final frame without having written any other frames
+		o.writeFrame(new byte[FRAME_LENGTH - MAC_LENGTH], 0, true);
+		// An empty final frame should be written to the output stream
+		assertEquals(HEADER_LENGTH + MAC_LENGTH, out.size());
+	}
+
+	@Test
+	public void testRemainingCapacityWithTag() throws Exception {
+		int MAX_PAYLOAD_LENGTH = FRAME_LENGTH - HEADER_LENGTH - MAC_LENGTH;
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		// Initiator's constructor
+		OutgoingEncryptionLayer o = new OutgoingEncryptionLayer(out,
+				10 * FRAME_LENGTH, frameCipher, crypto.generateTestKey(),
+				FRAME_LENGTH, tag);
+		// There should be space for nine full frames and one partial frame
+		byte[] frame = new byte[FRAME_LENGTH - MAC_LENGTH];
+		assertEquals(10 * MAX_PAYLOAD_LENGTH - TAG_LENGTH,
+				o.getRemainingCapacity());
+		// Write nine frames, each containing a partial payload
+		for(int i = 0; i < 9; i++) {
+			o.writeFrame(frame, 123, false);
+			assertEquals((9 - i) * MAX_PAYLOAD_LENGTH - TAG_LENGTH,
+					o.getRemainingCapacity());
+		}
+		// Write the final frame, which will not be padded
+		o.writeFrame(frame, 123, true);
+		int finalFrameLength = HEADER_LENGTH + 123 + MAC_LENGTH;
+		assertEquals(MAX_PAYLOAD_LENGTH - TAG_LENGTH - finalFrameLength,
+				o.getRemainingCapacity());
+	}
+
+	@Test
+	public void testRemainingCapacityWithoutTag() throws Exception {
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		// Responder's constructor
+		OutgoingEncryptionLayer o = new OutgoingEncryptionLayer(out,
+				10 * FRAME_LENGTH, frameCipher, crypto.generateTestKey(),
+				FRAME_LENGTH);
+		// There should be space for ten full frames
+		assertEquals(10 * MAX_PAYLOAD_LENGTH, o.getRemainingCapacity());
+		// Write nine frames, each containing a partial payload
+		byte[] frame = new byte[FRAME_LENGTH - MAC_LENGTH];
+		for(int i = 0; i < 9; i++) {
+			o.writeFrame(frame, 123, false);
+			assertEquals((9 - i) * MAX_PAYLOAD_LENGTH,
+					o.getRemainingCapacity());
+		}
+		// Write the final frame, which will not be padded
+		o.writeFrame(frame, 123, true);
+		int finalFrameLength = HEADER_LENGTH + 123 + MAC_LENGTH;
+		assertEquals(MAX_PAYLOAD_LENGTH - finalFrameLength,
+				o.getRemainingCapacity());
+	}
+
+	@Test
+	public void testRemainingCapacityLimitedByFrameNumbers() throws Exception {
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		// The connection has plenty of space so we're limited by frame numbers
+		OutgoingEncryptionLayer o = new OutgoingEncryptionLayer(out,
+				Long.MAX_VALUE, frameCipher, crypto.generateTestKey(),
+				FRAME_LENGTH);
+		// There should be enough frame numbers for 2^32 frames
+		assertEquals((1L << 32) * MAX_PAYLOAD_LENGTH, o.getRemainingCapacity());
+		// Write a frame containing a partial payload
+		byte[] frame = new byte[FRAME_LENGTH - MAC_LENGTH];
+		o.writeFrame(frame, 123, false);
+		// There should be enough frame numbers for 2^32 - 1 frames
+		assertEquals(((1L << 32) - 1) * MAX_PAYLOAD_LENGTH,
+				o.getRemainingCapacity());
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/transport/TransportConnectionRecogniserTest.java b/briar-tests/src/net/sf/briar/transport/TransportConnectionRecogniserTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..9e8739719f36298aed0509a38530cb26c0489283
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/transport/TransportConnectionRecogniserTest.java
@@ -0,0 +1,141 @@
+package net.sf.briar.transport;
+
+import static net.sf.briar.api.transport.TransportConstants.TAG_LENGTH;
+import static org.junit.Assert.assertArrayEquals;
+
+import java.util.Random;
+
+import javax.crypto.Cipher;
+import javax.crypto.NullCipher;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.TestUtils;
+import net.sf.briar.api.ContactId;
+import net.sf.briar.api.crypto.CryptoComponent;
+import net.sf.briar.api.crypto.ErasableKey;
+import net.sf.briar.api.db.DatabaseComponent;
+import net.sf.briar.api.protocol.TransportId;
+import net.sf.briar.api.transport.ConnectionContext;
+import net.sf.briar.api.transport.TemporarySecret;
+import net.sf.briar.util.ByteUtils;
+
+import org.hamcrest.Description;
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.jmock.api.Action;
+import org.jmock.api.Invocation;
+import org.junit.Test;
+
+public class TransportConnectionRecogniserTest extends BriarTestCase {
+
+	private final ContactId contactId = new ContactId(234);
+	private final TransportId transportId =
+			new TransportId(TestUtils.getRandomId());
+
+	@Test
+	public void testAddAndRemoveSecret() {
+		Mockery context = new Mockery();
+		final CryptoComponent crypto = context.mock(CryptoComponent.class);
+		final Cipher tagCipher = new NullCipher();
+		final byte[] secret = new byte[32];
+		new Random().nextBytes(secret);
+		final boolean alice = false;
+		final ErasableKey tagKey = context.mock(ErasableKey.class);
+		final DatabaseComponent db = context.mock(DatabaseComponent.class);
+		context.checking(new Expectations() {{
+			// Add secret
+			oneOf(crypto).getTagCipher();
+			will(returnValue(tagCipher));
+			oneOf(crypto).deriveTagKey(secret, !alice);
+			will(returnValue(tagKey));
+			exactly(16).of(crypto).encodeTag(with(any(byte[].class)),
+					with(tagCipher), with(tagKey), with(any(long.class)));
+			will(new EncodeTagAction());
+			oneOf(tagKey).erase();
+			// Remove secret
+			oneOf(crypto).getTagCipher();
+			will(returnValue(tagCipher));
+			oneOf(crypto).deriveTagKey(secret, !alice);
+			will(returnValue(tagKey));
+			exactly(16).of(crypto).encodeTag(with(any(byte[].class)),
+					with(tagCipher), with(tagKey), with(any(long.class)));
+			will(new EncodeTagAction());
+			oneOf(tagKey).erase();
+		}});
+		TemporarySecret s = new TemporarySecret(contactId, transportId, 0L,
+				0L, 0L, alice, 0L, secret, 0L, 0L, new byte[4]);
+		TransportConnectionRecogniser recogniser =
+				new TransportConnectionRecogniser(crypto, db, transportId);
+		recogniser.addSecret(s);
+		recogniser.removeSecret(contactId, 0L);
+		// The secret should have been erased
+		assertArrayEquals(new byte[32], secret);
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testAcceptConnection() throws Exception {
+		Mockery context = new Mockery();
+		final CryptoComponent crypto = context.mock(CryptoComponent.class);
+		final Cipher tagCipher = new NullCipher();
+		final byte[] secret = new byte[32];
+		new Random().nextBytes(secret);
+		final boolean alice = false;
+		final ErasableKey tagKey = context.mock(ErasableKey.class);
+		final DatabaseComponent db = context.mock(DatabaseComponent.class);
+		context.checking(new Expectations() {{
+			// Add secret
+			oneOf(crypto).getTagCipher();
+			will(returnValue(tagCipher));
+			oneOf(crypto).deriveTagKey(secret, !alice);
+			will(returnValue(tagKey));
+			exactly(16).of(crypto).encodeTag(with(any(byte[].class)),
+					with(tagCipher), with(tagKey), with(any(long.class)));
+			will(new EncodeTagAction());
+			oneOf(tagKey).erase();
+			// Accept connection
+			oneOf(crypto).getTagCipher();
+			will(returnValue(tagCipher));
+			oneOf(crypto).deriveTagKey(secret, !alice);
+			will(returnValue(tagKey));
+			// The window should slide to include connection 16
+			oneOf(crypto).encodeTag(with(any(byte[].class)), with(tagCipher),
+					with(tagKey), with(16L));
+			will(new EncodeTagAction());
+			// The updated window should be stored
+			oneOf(db).setConnectionWindow(contactId, transportId, 0L, 1L,
+					new byte[] {0, 1, 0, 0});
+			oneOf(tagKey).erase();
+			// Accept connection again - no expectations
+		}});
+		TemporarySecret s = new TemporarySecret(contactId, transportId, 0L,
+				0L, 0L, alice, 0L, secret, 0L, 0L, new byte[4]);
+		TransportConnectionRecogniser recogniser =
+				new TransportConnectionRecogniser(crypto, db, transportId);
+		recogniser.addSecret(s);
+		// Connection 0 should be expected
+		byte[] tag = new byte[TAG_LENGTH];
+		ConnectionContext ctx = recogniser.acceptConnection(tag);
+		assertNotNull(ctx);
+		assertEquals(contactId, ctx.getContactId());
+		assertEquals(transportId, ctx.getTransportId());
+		assertArrayEquals(secret, ctx.getSecret());
+		assertEquals(0L, ctx.getConnectionNumber());
+		assertEquals(alice, ctx.getAlice());
+		context.assertIsSatisfied();
+	}
+
+	private static class EncodeTagAction implements Action {
+
+		public void describeTo(Description description) {
+			description.appendText("Encodes a tag");
+		}
+
+		public Object invoke(Invocation invocation) throws Throwable {
+			byte[] tag = (byte[]) invocation.getParameter(0);
+			long connection = (Long) invocation.getParameter(3);
+			ByteUtils.writeUint32(connection, tag, 0);
+			return null;
+		}
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/transport/TransportIntegrationTest.java b/briar-tests/src/net/sf/briar/transport/TransportIntegrationTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..daa99b5b0233d657b1579c0f3b1b66614ed04468
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/transport/TransportIntegrationTest.java
@@ -0,0 +1,173 @@
+package net.sf.briar.transport;
+
+import static net.sf.briar.api.protocol.ProtocolConstants.MAX_PACKET_LENGTH;
+import static net.sf.briar.api.transport.TransportConstants.MIN_CONNECTION_LENGTH;
+import static org.junit.Assert.assertArrayEquals;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Random;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.TestUtils;
+import net.sf.briar.api.ContactId;
+import net.sf.briar.api.crypto.AuthenticatedCipher;
+import net.sf.briar.api.crypto.CryptoComponent;
+import net.sf.briar.api.crypto.ErasableKey;
+import net.sf.briar.api.protocol.TransportId;
+import net.sf.briar.api.transport.ConnectionContext;
+import net.sf.briar.api.transport.ConnectionReader;
+import net.sf.briar.api.transport.ConnectionWriter;
+import net.sf.briar.api.transport.ConnectionWriterFactory;
+import net.sf.briar.crypto.CryptoModule;
+import net.sf.briar.transport.ConnectionReaderImpl;
+import net.sf.briar.transport.ConnectionWriterFactoryImpl;
+import net.sf.briar.transport.ConnectionWriterImpl;
+import net.sf.briar.transport.IncomingEncryptionLayer;
+import net.sf.briar.transport.OutgoingEncryptionLayer;
+
+import org.junit.Test;
+
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.Module;
+
+public class TransportIntegrationTest extends BriarTestCase {
+
+	private final int FRAME_LENGTH = 2048;
+
+	private final CryptoComponent crypto;
+	private final ConnectionWriterFactory connectionWriterFactory;
+	private final ContactId contactId;
+	private final TransportId transportId;
+	private final AuthenticatedCipher frameCipher;
+	private final Random random;
+	private final byte[] secret;
+	private final ErasableKey frameKey;
+
+	public TransportIntegrationTest() {
+		super();
+		Module testModule = new AbstractModule() {
+			@Override
+			public void configure() {
+				bind(ConnectionWriterFactory.class).to(
+						ConnectionWriterFactoryImpl.class);
+			}
+		};
+		Injector i = Guice.createInjector(testModule, new CryptoModule());
+		crypto = i.getInstance(CryptoComponent.class);
+		connectionWriterFactory = i.getInstance(ConnectionWriterFactory.class);
+		contactId = new ContactId(234);
+		transportId = new TransportId(TestUtils.getRandomId());
+		frameCipher = crypto.getFrameCipher();
+		random = new Random();
+		// Since we're sending frames to ourselves, we only need outgoing keys
+		secret = new byte[32];
+		random.nextBytes(secret);
+		frameKey = crypto.deriveFrameKey(secret, 0L, true, true);
+	}
+
+	@Test
+	public void testInitiatorWriteAndRead() throws Exception {
+		testWriteAndRead(true);
+	}
+
+	@Test
+	public void testResponderWriteAndRead() throws Exception {
+		testWriteAndRead(false);
+	}
+
+	private void testWriteAndRead(boolean initiator) throws Exception {
+		// Generate two random frames
+		byte[] frame = new byte[1234];
+		random.nextBytes(frame);
+		byte[] frame1 = new byte[321];
+		random.nextBytes(frame1);
+		// Copy the frame key - the copy will be erased
+		ErasableKey frameCopy = frameKey.copy();
+		// Write the frames
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		FrameWriter encryptionOut = new OutgoingEncryptionLayer(out,
+				Long.MAX_VALUE, frameCipher, frameCopy, FRAME_LENGTH);
+		ConnectionWriter writer = new ConnectionWriterImpl(encryptionOut,
+				FRAME_LENGTH);
+		OutputStream out1 = writer.getOutputStream();
+		out1.write(frame);
+		out1.flush();
+		out1.write(frame1);
+		out1.flush();
+		byte[] output = out.toByteArray();
+		assertEquals(FRAME_LENGTH * 2, output.length);
+		// Read the tag and the frames back
+		ByteArrayInputStream in = new ByteArrayInputStream(output);
+		FrameReader encryptionIn = new IncomingEncryptionLayer(in, frameCipher,
+				frameKey, FRAME_LENGTH);
+		ConnectionReader reader = new ConnectionReaderImpl(encryptionIn,
+				FRAME_LENGTH);
+		InputStream in1 = reader.getInputStream();
+		byte[] recovered = new byte[frame.length];
+		int offset = 0;
+		while(offset < recovered.length) {
+			int read = in1.read(recovered, offset, recovered.length - offset);
+			if(read == -1) break;
+			offset += read;
+		}
+		assertEquals(recovered.length, offset);
+		assertArrayEquals(frame, recovered);
+		byte[] recovered1 = new byte[frame1.length];
+		offset = 0;
+		while(offset < recovered1.length) {
+			int read = in1.read(recovered1, offset, recovered1.length - offset);
+			if(read == -1) break;
+			offset += read;
+		}
+		assertEquals(recovered1.length, offset);
+		assertArrayEquals(frame1, recovered1);
+	}
+
+	@Test
+	public void testOverheadWithTag() throws Exception {
+		ByteArrayOutputStream out =
+				new ByteArrayOutputStream(MIN_CONNECTION_LENGTH);
+		ConnectionContext ctx = new ConnectionContext(contactId, transportId,
+				secret, 0L, true);
+		ConnectionWriter w = connectionWriterFactory.createConnectionWriter(out,
+				MIN_CONNECTION_LENGTH, ctx, false, true);
+		// Check that the connection writer thinks there's room for a packet
+		long capacity = w.getRemainingCapacity();
+		assertTrue(capacity > MAX_PACKET_LENGTH);
+		assertTrue(capacity < MIN_CONNECTION_LENGTH);
+		// Check that there really is room for a packet
+		byte[] payload = new byte[MAX_PACKET_LENGTH];
+		w.getOutputStream().write(payload);
+		w.getOutputStream().close();
+		long used = out.size();
+		assertTrue(used > MAX_PACKET_LENGTH);
+		assertTrue(used <= MIN_CONNECTION_LENGTH);
+	}
+
+	@Test
+	public void testOverheadWithoutTag() throws Exception {
+		ByteArrayOutputStream out =
+				new ByteArrayOutputStream(MIN_CONNECTION_LENGTH);
+		ConnectionContext ctx = new ConnectionContext(contactId, transportId,
+				secret, 0L, true);
+		ConnectionWriter w = connectionWriterFactory.createConnectionWriter(out,
+				MIN_CONNECTION_LENGTH, ctx, false, false);
+		// Check that the connection writer thinks there's room for a packet
+		long capacity = w.getRemainingCapacity();
+		assertTrue(capacity > MAX_PACKET_LENGTH);
+		assertTrue(capacity < MIN_CONNECTION_LENGTH);
+		// Check that there really is room for a packet
+		byte[] payload = new byte[MAX_PACKET_LENGTH];
+		w.getOutputStream().write(payload);
+		w.getOutputStream().close();
+		long used = out.size();
+		assertTrue(used > MAX_PACKET_LENGTH);
+		assertTrue(used <= MIN_CONNECTION_LENGTH);
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/util/ByteUtilsTest.java b/briar-tests/src/net/sf/briar/util/ByteUtilsTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..c672bc0e2af7e72bbcce2171f27a72952cbfa775
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/util/ByteUtilsTest.java
@@ -0,0 +1,66 @@
+package net.sf.briar.util;
+
+import net.sf.briar.BriarTestCase;
+
+import org.junit.Test;
+
+public class ByteUtilsTest extends BriarTestCase {
+
+	@Test
+	public void testReadUint16() {
+		byte[] b = StringUtils.fromHexString("000000");
+		assertEquals(0, ByteUtils.readUint16(b, 1));
+		b = StringUtils.fromHexString("000001");
+		assertEquals(1, ByteUtils.readUint16(b, 1));
+		b = StringUtils.fromHexString("00FFFF");
+		assertEquals(65535, ByteUtils.readUint16(b, 1));
+	}
+
+	@Test
+	public void testReadUint32() {
+		byte[] b = StringUtils.fromHexString("0000000000");
+		assertEquals(0L, ByteUtils.readUint32(b, 1));
+		b = StringUtils.fromHexString("0000000001");
+		assertEquals(1L, ByteUtils.readUint32(b, 1));
+		b = StringUtils.fromHexString("00FFFFFFFF");
+		assertEquals(4294967295L, ByteUtils.readUint32(b, 1));
+	}
+
+
+	@Test
+	public void testWriteUint16() {
+		byte[] b = new byte[3];
+		ByteUtils.writeUint16(0, b, 1);
+		assertEquals("000000", StringUtils.toHexString(b));
+		ByteUtils.writeUint16(1, b, 1);
+		assertEquals("000001", StringUtils.toHexString(b));
+		ByteUtils.writeUint16(65535, b, 1);
+		assertEquals("00FFFF", StringUtils.toHexString(b));
+	}
+
+	@Test
+	public void testWriteUint32() {
+		byte[] b = new byte[5];
+		ByteUtils.writeUint32(0L, b, 1);
+		assertEquals("0000000000", StringUtils.toHexString(b));
+		ByteUtils.writeUint32(1L, b, 1);
+		assertEquals("0000000001", StringUtils.toHexString(b));
+		ByteUtils.writeUint32(4294967295L, b, 1);
+		assertEquals("00FFFFFFFF", StringUtils.toHexString(b));
+	}
+
+	@Test
+	public void testReadUint() {
+		byte[] b = new byte[1];
+		b[0] = (byte) 128;
+		for(int i = 0; i < 8; i++) {
+			assertEquals(1 << i, ByteUtils.readUint(b, i + 1));
+		}
+		b = new byte[2];
+		for(int i = 0; i < 65535; i++) {
+			ByteUtils.writeUint16(i, b, 0);
+			assertEquals(i, ByteUtils.readUint(b, 16));
+			assertEquals(i >> 1, ByteUtils.readUint(b, 15));
+		}
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/util/FileUtilsTest.java b/briar-tests/src/net/sf/briar/util/FileUtilsTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..251d78a6e8aec8ce28c5779972237dac8832ab80
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/util/FileUtilsTest.java
@@ -0,0 +1,165 @@
+package net.sf.briar.util;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Scanner;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.TestUtils;
+import net.sf.briar.util.FileUtils.Callback;
+
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class FileUtilsTest extends BriarTestCase {
+
+	private final File testDir = TestUtils.getTestDirectory();
+
+	@Before
+	public void setUp() {
+		testDir.mkdirs();
+	}
+
+	@Test
+	public void testCreateTempFile() throws IOException {
+		File temp = FileUtils.createTempFile();
+		assertTrue(temp.exists());
+		assertTrue(temp.isFile());
+		assertEquals(0L, temp.length());
+		temp.delete();
+	}
+
+	@Test
+	public void testCopy() throws IOException {
+		File src = new File(testDir, "src");
+		File dest = new File(testDir, "dest");
+		TestUtils.createFile(src, "Foo bar\r\nBar foo\r\n");
+		long length = src.length();
+
+		FileUtils.copy(src, dest);
+
+		assertEquals(length, dest.length());
+		Scanner in = new Scanner(dest);
+		assertTrue(in.hasNextLine());
+		assertEquals("Foo bar", in.nextLine());
+		assertTrue(in.hasNextLine());
+		assertEquals("Bar foo", in.nextLine());
+		assertFalse(in.hasNext());
+		in.close();
+	}
+
+	@Test
+	public void testCopyFromStream() throws IOException {
+		File src = new File(testDir, "src");
+		File dest = new File(testDir, "dest");
+		TestUtils.createFile(src, "Foo bar\r\nBar foo\r\n");
+		long length = src.length();
+		InputStream is = new FileInputStream(src);
+		is.skip(4);
+
+		FileUtils.copy(is, dest);
+
+		assertEquals(length - 4, dest.length());
+		Scanner in = new Scanner(dest);
+		assertTrue(in.hasNextLine());
+		assertEquals("bar", in.nextLine());
+		assertTrue(in.hasNextLine());
+		assertEquals("Bar foo", in.nextLine());
+		assertFalse(in.hasNext());
+		in.close();
+	}
+
+	@Test
+	public void testCopyRecursively() throws IOException {
+		final File dest1 = new File(testDir, "dest/abc/def/1");
+		final File dest2 = new File(testDir, "dest/abc/def/2");
+		final File dest3 = new File(testDir, "dest/abc/3");
+		Mockery context = new Mockery();
+		final Callback callback = context.mock(Callback.class);
+		context.checking(new Expectations() {{
+			oneOf(callback).processingFile(dest1);
+			oneOf(callback).processingFile(dest2);
+			oneOf(callback).processingFile(dest3);
+		}});
+
+		copyRecursively(callback);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testCopyRecursivelyNoCallback() throws IOException {
+		copyRecursively(null);
+	}
+
+	private void copyRecursively(Callback callback) throws IOException {
+		TestUtils.createFile(new File(testDir, "abc/def/1"), "one one one");
+		TestUtils.createFile(new File(testDir, "abc/def/2"), "two two two");
+		TestUtils.createFile(new File(testDir, "abc/3"), "three three three");
+
+		File dest = new File(testDir, "dest");
+		dest.mkdir();
+
+		FileUtils.copyRecursively(new File(testDir, "abc"), dest, callback);
+
+		File dest1 = new File(testDir, "dest/abc/def/1");
+		assertTrue(dest1.exists());
+		assertTrue(dest1.isFile());
+		assertEquals("one one one".length(), dest1.length());
+		File dest2 = new File(testDir, "dest/abc/def/2");
+		assertTrue(dest2.exists());
+		assertTrue(dest2.isFile());
+		assertEquals("two two two".length(), dest2.length());
+		File dest3 = new File(testDir, "dest/abc/3");
+		assertTrue(dest3.exists());
+		assertTrue(dest3.isFile());
+		assertEquals("three three three".length(), dest3.length());
+	}
+
+	@Test
+	public void testDeleteFile() throws IOException {
+		File foo = new File(testDir, "foo");
+		foo.createNewFile();
+		assertTrue(foo.exists());
+
+		FileUtils.delete(foo);
+
+		assertFalse(foo.exists());
+	}
+
+	@Test
+	public void testDeleteDirectory() throws IOException {
+		File f1 = new File(testDir, "abc/def/1");
+		File f2 = new File(testDir, "abc/def/2");
+		File f3 = new File(testDir, "abc/3");
+		File abc = new File(testDir, "abc");
+		File def = new File(testDir, "abc/def");
+		TestUtils.createFile(f1, "one one one");
+		TestUtils.createFile(f2, "two two two");
+		TestUtils.createFile(f3, "three three three");
+
+		assertTrue(f1.exists());
+		assertTrue(f2.exists());
+		assertTrue(f3.exists());
+		assertTrue(abc.exists());
+		assertTrue(def.exists());
+
+		FileUtils.delete(def);
+
+		assertFalse(f1.exists());
+		assertFalse(f2.exists());
+		assertTrue(f3.exists());
+		assertTrue(abc.exists());
+		assertFalse(def.exists());
+	}
+
+	@After
+	public void tearDown() {
+		TestUtils.deleteTestDirectory(testDir);
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/util/StringUtilsTest.java b/briar-tests/src/net/sf/briar/util/StringUtilsTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..d4465e062e688152ccae97c2cc9205085b7d19ce
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/util/StringUtilsTest.java
@@ -0,0 +1,44 @@
+package net.sf.briar.util;
+
+import static org.junit.Assert.assertArrayEquals;
+import net.sf.briar.BriarTestCase;
+
+import org.junit.Test;
+
+public class StringUtilsTest extends BriarTestCase {
+
+	@Test
+	public void testHead() {
+		String head = StringUtils.head("123456789", 5);
+		assertEquals("12345...", head);
+	}
+
+	@Test
+	public void testTail() {
+		String tail = StringUtils.tail("987654321", 5);
+		assertEquals("...54321", tail);
+	}
+
+	@Test
+	public void testToHexString() {
+		byte[] b = new byte[] {1, 2, 3, 127, -128};
+		String s = StringUtils.toHexString(b);
+		assertEquals("0102037F80", s);
+	}
+
+	@Test
+	public void testFromHexString() {
+		try {
+			StringUtils.fromHexString("12345");
+			fail();
+		} catch(IllegalArgumentException expected) {}
+		try {
+			StringUtils.fromHexString("ABCDEFGH");
+			fail();
+		} catch(IllegalArgumentException expected) {}
+		byte[] b = StringUtils.fromHexString("0102037F80");
+		assertArrayEquals(new byte[] {1, 2, 3, 127, -128}, b);
+		b = StringUtils.fromHexString("0a0b0c0d0e0f");
+		assertArrayEquals(new byte[] {10, 11, 12, 13, 14, 15}, b);
+	}
+}
diff --git a/briar-tests/src/net/sf/briar/util/ZipUtilsTest.java b/briar-tests/src/net/sf/briar/util/ZipUtilsTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..c08d4a3700f407c5fe55a9e4ec4285f0752bd0b5
--- /dev/null
+++ b/briar-tests/src/net/sf/briar/util/ZipUtilsTest.java
@@ -0,0 +1,202 @@
+package net.sf.briar.util;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+import java.util.zip.ZipOutputStream;
+
+import net.sf.briar.BriarTestCase;
+import net.sf.briar.TestUtils;
+import net.sf.briar.util.ZipUtils.Callback;
+
+import org.jmock.Expectations;
+import org.jmock.Mockery;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ZipUtilsTest extends BriarTestCase {
+
+	private final File testDir = TestUtils.getTestDirectory();
+
+	private final File f1 = new File(testDir, "abc/def/1");
+	private final File f2 = new File(testDir, "abc/def/2");
+	private final File f3 = new File(testDir, "abc/3");
+
+	@Before
+	public void setUp() {
+		testDir.mkdirs();
+	}
+
+	@Test
+	public void testCopyToZip() throws IOException {
+		File src = new File(testDir, "src");
+		File dest = new File(testDir, "dest");
+		TestUtils.createFile(src, "foo bar baz");
+		ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(dest));
+
+		ZipUtils.copyToZip("abc/def", src, zip);
+		zip.flush();
+		zip.close();
+
+		Map<String, String> expected = Collections.singletonMap("abc/def",
+				"foo bar baz");
+		checkZipEntries(dest, expected);
+	}
+
+	private void checkZipEntries(File f, Map<String, String> expected)
+	throws IOException {
+		Map<String, String> found = new HashMap<String, String>();
+		assertTrue(f.exists());
+		assertTrue(f.isFile());
+		ZipInputStream unzip = new ZipInputStream(new FileInputStream(f));
+		ZipEntry entry;
+		while((entry = unzip.getNextEntry()) != null) {
+			String name = entry.getName();
+			Scanner s = new Scanner(unzip);
+			assertTrue(s.hasNextLine());
+			String contents = s.nextLine();
+			assertFalse(s.hasNextLine());
+			unzip.closeEntry();
+			found.put(name, contents);
+		}
+		unzip.close();
+		assertEquals(expected.size(), found.size());
+		for(String name : expected.keySet()) {
+			String contents = found.get(name);
+			assertNotNull(contents);
+			assertEquals(expected.get(name), contents);
+		}
+	}
+
+	@Test
+	public void testCopyToZipRecursively() throws IOException {
+		Mockery context = new Mockery();
+		final Callback callback = context.mock(Callback.class);
+		context.checking(new Expectations() {{
+			oneOf(callback).processingFile(f1);
+			oneOf(callback).processingFile(f2);
+			oneOf(callback).processingFile(f3);
+		}});
+
+		copyRecursively(callback);
+
+		context.assertIsSatisfied();
+	}
+
+	@Test
+	public void testCopyToZipRecursivelyNoCallback() throws IOException {
+		copyRecursively(null);
+	}
+
+	private void copyRecursively(Callback callback) throws IOException {
+		TestUtils.createFile(f1, "one one one");
+		TestUtils.createFile(f2, "two two two");
+		TestUtils.createFile(f3, "three three three");
+		File src = new File(testDir, "abc");
+		File dest = new File(testDir, "dest");
+		ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(dest));
+
+		ZipUtils.copyToZipRecursively("ghi", src, zip, callback);
+		zip.flush();
+		zip.close();
+
+		Map<String, String> expected = new HashMap<String, String>();
+		expected.put("ghi/def/1", "one one one");
+		expected.put("ghi/def/2", "two two two");
+		expected.put("ghi/3", "three three three");
+		checkZipEntries(dest, expected);
+	}
+
+	@Test
+	public void testUnzipStream() throws IOException {
+		Mockery context = new Mockery();
+		final Callback callback = context.mock(Callback.class);
+		context.checking(new Expectations() {{
+			oneOf(callback).processingFile(f1);
+			oneOf(callback).processingFile(f2);
+			oneOf(callback).processingFile(f3);
+		}});
+
+		unzipStream(null, callback);
+
+		context.assertIsSatisfied();
+
+		assertTrue(f1.exists());
+		assertTrue(f1.isFile());
+		assertEquals("one one one".length(), f1.length());
+		assertTrue(f2.exists());
+		assertTrue(f2.isFile());
+		assertEquals("two two two".length(), f2.length());
+		assertTrue(f3.exists());
+		assertTrue(f3.isFile());
+		assertEquals("three three three".length(), f3.length());
+	}
+
+	@Test
+	public void testUnzipStreamWithRegex() throws IOException {
+		Mockery context = new Mockery();
+		final Callback callback = context.mock(Callback.class);
+		context.checking(new Expectations() {{
+			oneOf(callback).processingFile(f1);
+			oneOf(callback).processingFile(f2);
+		}});
+
+		unzipStream("^abc/def/.*", callback);
+
+		context.assertIsSatisfied();
+
+		assertTrue(f1.exists());
+		assertTrue(f1.isFile());
+		assertEquals("one one one".length(), f1.length());
+		assertTrue(f2.exists());
+		assertTrue(f2.isFile());
+		assertEquals("two two two".length(), f2.length());
+		assertFalse(f3.exists());
+	}
+
+	@Test
+	public void testUnzipStreamNoCallback() throws IOException {
+		unzipStream(null, null);
+
+		assertTrue(f1.exists());
+		assertTrue(f1.isFile());
+		assertEquals("one one one".length(), f1.length());
+		assertTrue(f2.exists());
+		assertTrue(f2.isFile());
+		assertEquals("two two two".length(), f2.length());
+		assertTrue(f3.exists());
+		assertTrue(f3.isFile());
+		assertEquals("three three three".length(), f3.length());
+	}
+
+	private void unzipStream(String regex, Callback callback)
+	throws IOException {
+		TestUtils.createFile(f1, "one one one");
+		TestUtils.createFile(f2, "two two two");
+		TestUtils.createFile(f3, "three three three");
+		File src = new File(testDir, "abc");
+		File dest = new File(testDir, "dest");
+		ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(dest));
+		ZipUtils.copyToZipRecursively(src.getName(), src, zip, null);
+		zip.flush();
+		zip.close();
+		TestUtils.delete(src);
+
+		InputStream in = new FileInputStream(dest);
+		ZipUtils.unzipStream(in, testDir, regex, callback);
+	}
+
+	@After
+	public void tearDown() {
+		TestUtils.deleteTestDirectory(testDir);
+	}
+}