diff --git a/libs/jssc-0.9-briar.jar b/libs/jssc-0.9-briar.jar
new file mode 100644
index 0000000000000000000000000000000000000000..dd1b56b9ba69727bd197f1f1f6678e2c05b88e15
Binary files /dev/null and b/libs/jssc-0.9-briar.jar differ
diff --git a/src/net/sf/briar/plugins/modem/Modem.java b/src/net/sf/briar/plugins/modem/Modem.java
new file mode 100644
index 0000000000000000000000000000000000000000..d85a7bfacd4ba257d113aa250a9dcf26cb1ace8c
--- /dev/null
+++ b/src/net/sf/briar/plugins/modem/Modem.java
@@ -0,0 +1,35 @@
+package net.sf.briar.plugins.modem;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+interface Modem {
+
+	/**
+	 * Call this method once after creating the modem. If an exception is
+	 * thrown, the modem cannot be used.
+	 */
+	void init() throws IOException;
+
+	/**
+	 * Initiates an outgoing call and returns true if the call connects. If the
+	 * call does not connect the modem is hung up.
+	 */
+	boolean dial(String number) throws IOException;
+
+	/** Returns a stream for reading from the currently connected call. */
+	InputStream getInputStream();
+
+	/** Returns a stream for writing to the currently connected call. */
+	OutputStream getOutputStream();
+
+	/** Hangs up the modem, ending the currently connected call. */
+	void hangUp() throws IOException;
+
+	interface Callback {
+
+		/** Called when an incoming call connects. */
+		void incomingCallConnected();
+	}
+}
diff --git a/src/net/sf/briar/plugins/modem/ModemImpl.java b/src/net/sf/briar/plugins/modem/ModemImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..be4515fa57adf1f24d0bb6d27bca7e333ce0a7c7
--- /dev/null
+++ b/src/net/sf/briar/plugins/modem/ModemImpl.java
@@ -0,0 +1,304 @@
+package net.sf.briar.plugins.modem;
+
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+import static jssc.SerialPort.PURGE_RXCLEAR;
+import static jssc.SerialPort.PURGE_TXCLEAR;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.Logger;
+
+import jssc.SerialPort;
+import jssc.SerialPortEvent;
+import jssc.SerialPortEventListener;
+import jssc.SerialPortException;
+
+class ModemImpl implements Modem, SerialPortEventListener {
+
+	private static final Logger LOG =
+			Logger.getLogger(ModemImpl.class.getName());
+	private static final int MAX_LINE_LENGTH = 256;
+	private static final int[] BAUD_RATES = {
+		256000, 128000, 115200, 57600, 38400, 19200, 14400, 9600, 4800, 1200
+	};
+	private static final int OK_TIMEOUT = 5 * 1000; // Milliseconds
+	private static final int CONNECT_TIMEOUT = 60 * 1000; // Milliseconds
+
+	private final Callback callback;
+	private final SerialPort port;
+	private final AtomicBoolean initialised, offHook, connected;
+	private final byte[] line;
+
+	private int lineLen = 0;
+
+	// A fresh queue is used for each connection
+	private volatile BlockingQueue<byte[]> received;
+
+	ModemImpl(Callback callback, String portName) {
+		this.callback = callback;
+		port = new SerialPort(portName);
+		initialised = new AtomicBoolean(false);
+		offHook = new AtomicBoolean(false);
+		connected = new AtomicBoolean(false);
+		line = new byte[MAX_LINE_LENGTH];
+		received = new LinkedBlockingQueue<byte[]>();
+	}
+
+	public void init() throws IOException {
+		if(LOG.isLoggable(INFO)) LOG.info("Initialising");
+		try {
+			// Open the serial port
+			if(!port.openPort())
+				throw new IOException("Failed to open serial port");
+			// Find a suitable baud rate
+			boolean foundBaudRate = false;
+			for(int baudRate : BAUD_RATES) {
+				if(port.setParams(baudRate, 8, 1, 0)) {
+					foundBaudRate = true;
+					break;
+				}
+			}
+			if(!foundBaudRate)
+				throw new IOException("Could not find a suitable baud rate");
+			// Listen for incoming data and hangup events
+			port.addEventListener(this);
+			// Initialise the modem
+			port.purgePort(PURGE_RXCLEAR | PURGE_TXCLEAR);
+			port.writeBytes("ATZ\r\n".getBytes("US-ASCII")); // Reset
+			port.writeBytes("ATE0\r\n".getBytes("US-ASCII")); // Echo off
+		} catch(SerialPortException e) {
+			throw new IOException(e.toString());
+		}
+		try {
+			// Wait for the modem to respond "OK"
+			synchronized(initialised) {
+				if(!initialised.get()) initialised.wait(OK_TIMEOUT);
+				if(!initialised.get())
+					throw new IOException("Modem did not respond");
+			}
+		} catch(InterruptedException e) {
+			if(LOG.isLoggable(WARNING))
+				LOG.warning("Interrupted while initialising modem");
+			Thread.currentThread().interrupt();
+			throw new IOException("Interrupted while initialising modem");
+		}
+	}
+
+	public boolean dial(String number) throws IOException {
+		if(offHook.getAndSet(true)) {
+			if(LOG.isLoggable(INFO))
+				LOG.info("Not dialling - call in progress");
+			return false;
+		}
+		if(LOG.isLoggable(INFO)) LOG.info("Dialling");
+		try {
+			port.writeBytes(("ATDT" + number + "\r\n").getBytes("US-ASCII"));
+		} catch(SerialPortException e) {
+			throw new IOException(e.toString());
+		}
+		try {
+			synchronized(connected) {
+				if(!connected.get()) connected.wait(CONNECT_TIMEOUT);
+			}
+		} catch(InterruptedException e) {
+			if(LOG.isLoggable(WARNING))
+				LOG.warning("Interrupted while connecting outgoing call");
+			Thread.currentThread().interrupt();
+			throw new IOException("Interrupted while connecting outgoing call");
+		}
+		if(connected.get()) return true;
+		hangUp();
+		return false;
+	}
+
+	public InputStream getInputStream() {
+		return new ModemInputStream(received);
+	}
+
+	public OutputStream getOutputStream() {
+		return new ModemOutputStream();
+	}
+
+	public void hangUp() throws IOException {
+		if(LOG.isLoggable(INFO)) LOG.info("Hanging up");
+		try {
+			port.setDTR(false);
+		} catch(SerialPortException e) {
+			throw new IOException(e.toString());
+		}
+		received.add(new byte[0]); // Empty buffer indicates EOF
+		received = new LinkedBlockingQueue<byte[]>();
+		connected.set(false);
+		offHook.set(false);
+	}
+
+	public void serialEvent(SerialPortEvent ev) {
+		try {
+			if(ev.isRXCHAR()) {
+				byte[] b = port.readBytes();
+				if(connected.get()) received.add(b);
+				else handleText(b);
+			} else if(ev.isDSR() && ev.getEventValue() == 0) {
+				if(LOG.isLoggable(INFO)) LOG.info("Remote end hung up");
+				hangUp();
+			}
+		} catch(IOException e) {
+			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
+		} catch(SerialPortException e) {
+			if(LOG.isLoggable(WARNING)) LOG.warning(e.toString());
+		}
+	}
+
+	private void handleText(byte[] b) throws IOException {
+		if(lineLen + b.length > MAX_LINE_LENGTH)
+			throw new IOException("Line too long");
+		for(int i = 0; i < b.length; i++) {
+			line[lineLen] = b[i];
+			if(b[i] == '\n') {
+				String s = new String(line, 0, lineLen, "US-ASCII").trim();
+				lineLen = 0;
+				if(LOG.isLoggable(INFO)) LOG.info("Modem status: " + s);
+				if(s.startsWith("CONNECT")) {
+					// There might be data in the buffer as well as text
+					int off = i + 1;
+					if(off < b.length) {
+						byte[] data = new byte[b.length - off];
+						System.arraycopy(b, off, data, 0, data.length);
+						received.add(data);
+					}
+					synchronized(connected) {
+						if(!connected.getAndSet(true))
+							connected.notifyAll();
+					}
+					return;
+				} else if(s.equals("OK")) {
+					synchronized(initialised) {
+						if(!initialised.getAndSet(true))
+							initialised.notifyAll();
+					}
+				} else if(s.equals("RING")) {
+					// FIXME: Don't do this on the event thread
+					answer();
+				}
+			} else {
+				lineLen++;
+			}
+		}
+	}
+
+	private void answer() throws IOException {
+		if(offHook.getAndSet(true)) {
+			if(LOG.isLoggable(INFO))
+				LOG.info("Not answering - call in progress");
+			return;
+		}
+		if(LOG.isLoggable(INFO)) LOG.info("Answering");
+		try {
+			port.writeBytes("ATA\r\n".getBytes("US-ASCII"));
+		} catch(SerialPortException e) {
+			throw new IOException(e.toString());
+		}
+		try {
+			synchronized(connected) {
+				if(!connected.get()) connected.wait(CONNECT_TIMEOUT);
+			}
+		} catch(InterruptedException e) {
+			if(LOG.isLoggable(WARNING))
+				LOG.warning("Interrupted while connecting incoming call");
+			Thread.currentThread().interrupt();
+			throw new IOException("Interrupted while connecting incoming call");
+		}
+		if(connected.get()) callback.incomingCallConnected();
+		else hangUp();
+	}
+
+	private static class ModemInputStream extends InputStream {
+
+		private final BlockingQueue<byte[]> received;
+
+		private byte[] buf = null;
+		private int offset = 0;
+
+		private ModemInputStream(BlockingQueue<byte[]> received) {
+			this.received = received;
+		}
+
+		@Override
+		public int read() throws IOException {
+			getBufferIfNecessary();
+			if(buf.length == 0) return -1;
+			return buf[offset++];
+		}
+
+		@Override
+		public int read(byte[] b) throws IOException {
+			getBufferIfNecessary();
+			if(buf.length == 0) return -1;
+			int len = Math.min(b.length, buf.length - offset);
+			System.arraycopy(buf, offset, b, 0, len);
+			offset += len;
+			return len;
+		}
+
+		@Override
+		public int read(byte[] b, int off, int len) throws IOException {
+			getBufferIfNecessary();
+			if(buf.length == 0) return -1;
+			len = Math.min(len, buf.length - offset);
+			System.arraycopy(buf, offset, b, off, len);
+			offset += len;
+			return len;
+		}
+
+		private void getBufferIfNecessary() throws IOException {
+			if(buf == null || offset == buf.length) {
+				try {
+					buf = received.take();
+				} catch(InterruptedException e) {
+					if(LOG.isLoggable(WARNING))
+						LOG.warning("Interrupted while reading");
+					Thread.currentThread().interrupt();
+					throw new IOException(e.toString());
+				}
+				offset = 0;
+			}
+		}
+	}
+
+	private class ModemOutputStream extends OutputStream {
+
+		@Override
+		public void write(int b) throws IOException {
+			try {
+				port.writeByte((byte) b);
+			} catch(SerialPortException e) {
+				throw new IOException(e.toString());
+			}
+		}
+
+		@Override
+		public void write(byte[] b) throws IOException {
+			try {
+				port.writeBytes(b);
+			} catch(SerialPortException e) {
+				throw new IOException(e.toString());
+			}
+		}
+
+		@Override
+		public void write(byte[] b, int off, int len) throws IOException {
+			if(len < b.length) {
+				byte[] copy = new byte[len];
+				System.arraycopy(b, off, copy, 0, len);
+				write(copy);
+			} else {
+				write(b);
+			}
+		}
+	}
+}