diff --git a/components/net/sf/briar/transport/FrameScheduler.java b/components/net/sf/briar/transport/FrameScheduler.java
new file mode 100644
index 0000000000000000000000000000000000000000..955f6ab89790dfd77d1bea18a9cb88627a1658c3
--- /dev/null
+++ b/components/net/sf/briar/transport/FrameScheduler.java
@@ -0,0 +1,36 @@
+package net.sf.briar.transport;
+
+import static net.sf.briar.api.transport.TransportConstants.MAX_FRAME_LENGTH;
+
+/**
+ * A thread that calls the writeFullFrame() method of a PaddedConnectionWriter
+ * at regular intervals. The interval between calls is determined by a target
+ * output rate. If the underlying output stream cannot accept data at the
+ * target rate, calls will be made as frequently as the output stream allows.
+ */
+class FrameScheduler extends Thread {
+
+	private final PaddedConnectionWriter writer;
+	private final int millisPerFrame;
+
+	FrameScheduler(PaddedConnectionWriter writer, int bytesPerSecond) {
+		this.writer = writer;
+		millisPerFrame = bytesPerSecond * 1000 / MAX_FRAME_LENGTH;
+	}
+
+	@Override
+	public void run() {
+		long lastCall = System.currentTimeMillis();
+		while(true) {
+			long now = System.currentTimeMillis();
+			long nextCall = lastCall + millisPerFrame;
+			if(nextCall > now) {
+				try {
+					Thread.sleep(nextCall - now);
+				} catch(InterruptedException ignored) {}
+			}
+			lastCall = System.currentTimeMillis();
+			if(!writer.writeFullFrame()) return;
+		}
+	}
+}
\ No newline at end of file
diff --git a/components/net/sf/briar/transport/PaddedConnectionWriter.java b/components/net/sf/briar/transport/PaddedConnectionWriter.java
new file mode 100644
index 0000000000000000000000000000000000000000..dcab42eb10c8a84297772b1a77922406cc7248ab
--- /dev/null
+++ b/components/net/sf/briar/transport/PaddedConnectionWriter.java
@@ -0,0 +1,134 @@
+package net.sf.briar.transport;
+
+import static net.sf.briar.api.transport.TransportConstants.MAX_FRAME_LENGTH;
+import static net.sf.briar.util.ByteUtils.MAX_32_BIT_UNSIGNED;
+
+import java.io.ByteArrayOutputStream;
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+import javax.crypto.Mac;
+
+import net.sf.briar.api.transport.ConnectionWriter;
+import net.sf.briar.util.ByteUtils;
+
+/**
+ * A ConnectionWriter that uses padding to hinder traffic analysis. A full-size
+ * frame is written each time the writeFrame() method is called, with padding
+ * inserted if necessary. Calls to the writer's write() methods will block
+ * until there is space to buffer the data.
+ */
+class PaddedConnectionWriter extends FilterOutputStream
+implements ConnectionWriter {
+
+	private final ConnectionEncrypter encrypter;
+	private final Mac mac;
+	private final int maxPayloadLength;
+	private final ByteArrayOutputStream buf;
+	private final byte[] header, padding;
+
+	private long frame = 0L;
+	private boolean closed = false;
+	private IOException exception = null;
+
+	PaddedConnectionWriter(ConnectionEncrypter encrypter, Mac mac) {
+		super(encrypter.getOutputStream());
+		this.encrypter = encrypter;
+		this.mac = mac;
+		maxPayloadLength = MAX_FRAME_LENGTH - 8 - mac.getMacLength();
+		buf = new ByteArrayOutputStream(maxPayloadLength);
+		header = new byte[8];
+		padding = new byte[maxPayloadLength];
+	}
+
+	public OutputStream getOutputStream() {
+		return this;
+	}
+
+	@Override
+	public synchronized void close() throws IOException {
+		if(exception != null) throw exception;
+		if(buf.size() > 0) writeFrame(false);
+		out.flush();
+		out.close();
+		closed = true;
+	}
+
+	@Override
+	public void flush() throws IOException {
+		// Na na na, I can't hear you
+	}
+
+	@Override
+	public synchronized void write(int b) throws IOException {
+		if(exception != null) throw exception;
+		if(buf.size() == maxPayloadLength) waitForSpace();
+		buf.write(b);
+	}
+
+	@Override
+	public void write(byte[] b) throws IOException {
+		write(b, 0, b.length);
+	}
+
+	@Override
+	public synchronized void write(byte[] b, int off, int len)
+	throws IOException {
+		if(exception != null) throw exception;
+		int available = maxPayloadLength - buf.size();
+		while(available < len) {
+			buf.write(b, off, available);
+			off += available;
+			len -= available;
+			waitForSpace();
+			available = maxPayloadLength;
+		}
+		buf.write(b, off, len);
+	}
+
+	/**
+	 * Attempts to write a full-size frame, inserting padding if necessary, and
+	 * returns true if the frame was written. If this method returns false it
+	 * should not be called again.
+	 */
+	synchronized boolean writeFullFrame() {
+		if(closed) return false;
+		try {
+			writeFrame(true);
+			notify();
+			return true;
+		} catch(IOException e) {
+			exception = e;
+			return false;
+		}
+	}
+
+	private synchronized void writeFrame(boolean pad) throws IOException {
+		if(frame > MAX_32_BIT_UNSIGNED) throw new IllegalStateException();
+		byte[] payload = buf.toByteArray();
+		if(payload.length > maxPayloadLength) throw new IllegalStateException();
+		int paddingLength = pad ? maxPayloadLength - payload.length : 0;
+		ByteUtils.writeUint32(frame, header, 0);
+		ByteUtils.writeUint16(payload.length, header, 4);
+		ByteUtils.writeUint16(paddingLength, header, 6);
+		out.write(header);
+		mac.update(header);
+		out.write(payload);
+		mac.update(payload);
+		out.write(padding, 0, paddingLength);
+		mac.update(padding, 0, paddingLength);
+		encrypter.writeMac(mac.doFinal());
+		frame++;
+		buf.reset();
+	}
+
+	private synchronized void waitForSpace() throws IOException {
+		try {
+			wait();
+		} catch(InterruptedException e) {
+			throw new IOException(e);
+		}
+		if(exception != null) throw exception;
+	}
+}