From 0dc869228b49515bb885cec73852aa63478130e5 Mon Sep 17 00:00:00 2001
From: akwizgran <akwizgran@users.sourceforge.net>
Date: Thu, 19 Dec 2013 21:53:26 +0000
Subject: [PATCH] Replaced private messages with private groups.

Private messages are now the same as group messages, but groups can be
private or public. When a contact is added, a private group is created
and designated as the inbox for exchanging private messages with the
contact.
---
 briar-android/res/values/strings.xml          |    1 +
 .../android/DescendingHeaderComparator.java   |   19 -
 .../net/sf/briar/android/SetupActivity.java   |   10 +-
 .../android/contact/ContactListActivity.java  |  110 +-
 .../android/contact/ContactListItem.java      |   36 +-
 .../contact/ContactSpinnerAdapter.java        |   77 --
 .../android/contact/ConversationActivity.java |   56 +-
 .../android/contact/ConversationAdapter.java  |   14 +-
 .../android/contact/ConversationItem.java     |   37 +
 .../contact/ConversationItemComparator.java   |   19 +
 .../contact/ReadPrivateMessageActivity.java   |    6 +
 .../contact/WritePrivateMessageActivity.java  |  167 +--
 .../groups/ConfigureGroupActivity.java        |    6 +-
 .../android/groups/CreateGroupActivity.java   |    7 +-
 .../briar/android/groups/GroupActivity.java   |   19 +-
 .../sf/briar/android/groups/GroupAdapter.java |    8 +-
 .../android/groups/GroupListActivity.java     |   45 +-
 .../briar/android/groups/GroupListItem.java   |   27 +-
 .../android/groups/ManageGroupsActivity.java  |   25 +-
 .../groups/WriteGroupPostActivity.java        |   11 +-
 .../identity/CreateIdentityActivity.java      |   10 +-
 .../src/net/sf/briar/api/AuthorFactory.java   |    6 +-
 .../sf/briar/api/crypto/CryptoComponent.java  |    3 +
 .../sf/briar/api/db/DatabaseComponent.java    |  110 +-
 .../sf/briar/api/db/GroupMessageHeader.java   |   22 -
 .../net/sf/briar/api/db/MessageHeader.java    |   16 +-
 .../sf/briar/api/db/PrivateMessageHeader.java |   32 -
 .../api/db/event/GroupMessageAddedEvent.java  |   23 -
 .../api/db/event/LocalAuthorAddedEvent.java   |    2 +-
 .../api/db/event/LocalAuthorRemovedEvent.java |    2 +-
 .../briar/api/db/event/MessageAddedEvent.java |   29 +
 .../db/event/PrivateMessageAddedEvent.java    |   25 -
 .../src/net/sf/briar/api/messaging/Group.java |    9 +-
 .../sf/briar/api/messaging/GroupFactory.java  |    6 +-
 .../briar/api/messaging/MessageFactory.java   |    7 -
 .../api/messaging/MessagingConstants.java     |    2 +-
 .../api/messaging/SubscriptionUpdate.java     |    8 +-
 .../api/messaging/UnverifiedMessage.java      |   16 +-
 .../sf/briar/crypto/CryptoComponentImpl.java  |    9 +
 briar-core/src/net/sf/briar/db/Database.java  |  209 ++--
 .../sf/briar/db/DatabaseComponentImpl.java    |  503 ++++----
 .../src/net/sf/briar/db/JdbcDatabase.java     | 1042 ++++++++---------
 .../sf/briar/invitation/AliceConnector.java   |   14 +-
 .../net/sf/briar/invitation/BobConnector.java |   14 +-
 .../net/sf/briar/invitation/Connector.java    |   16 +-
 .../sf/briar/invitation/ConnectorGroup.java   |   17 +-
 .../invitation/InvitationTaskFactoryImpl.java |   13 +-
 .../sf/briar/messaging/AuthorFactoryImpl.java |   20 +-
 .../sf/briar/messaging/GroupFactoryImpl.java  |   22 +-
 .../net/sf/briar/messaging/GroupReader.java   |    3 +-
 .../briar/messaging/MessageFactoryImpl.java   |   11 +-
 .../net/sf/briar/messaging/MessageReader.java |   24 +-
 .../briar/messaging/MessageVerifierImpl.java  |   13 +-
 .../sf/briar/messaging/PacketWriterImpl.java  |    1 +
 .../messaging/SubscriptionUpdateReader.java   |    8 +-
 .../messaging/duplex/DuplexConnection.java    |   11 +-
 .../net/sf/briar/ProtocolIntegrationTest.java |    2 +-
 .../sf/briar/db/DatabaseComponentTest.java    |  415 ++-----
 .../src/net/sf/briar/db/H2DatabaseTest.java   |  387 +++---
 .../net/sf/briar/messaging/ConstantsTest.java |    8 +-
 .../SimplexMessagingIntegrationTest.java      |   22 +-
 61 files changed, 1600 insertions(+), 2212 deletions(-)
 delete mode 100644 briar-android/src/net/sf/briar/android/DescendingHeaderComparator.java
 delete mode 100644 briar-android/src/net/sf/briar/android/contact/ContactSpinnerAdapter.java
 create mode 100644 briar-android/src/net/sf/briar/android/contact/ConversationItem.java
 create mode 100644 briar-android/src/net/sf/briar/android/contact/ConversationItemComparator.java
 delete mode 100644 briar-api/src/net/sf/briar/api/db/GroupMessageHeader.java
 delete mode 100644 briar-api/src/net/sf/briar/api/db/PrivateMessageHeader.java
 delete mode 100644 briar-api/src/net/sf/briar/api/db/event/GroupMessageAddedEvent.java
 create mode 100644 briar-api/src/net/sf/briar/api/db/event/MessageAddedEvent.java
 delete mode 100644 briar-api/src/net/sf/briar/api/db/event/PrivateMessageAddedEvent.java

diff --git a/briar-android/res/values/strings.xml b/briar-android/res/values/strings.xml
index 8406c17870..7dabb29c79 100644
--- a/briar-android/res/values/strings.xml
+++ b/briar-android/res/values/strings.xml
@@ -52,6 +52,7 @@
 	<string name="messages_title">Messages</string>
 	<string name="no_messages">(No messages)</string>
 	<string name="format_from">From: %1$s</string>
+	<string name="format_to">To: %1$s</string>
 	<string name="new_message_title">New Message</string>
 	<string name="from">From:</string>
 	<string name="to">To:</string>
diff --git a/briar-android/src/net/sf/briar/android/DescendingHeaderComparator.java b/briar-android/src/net/sf/briar/android/DescendingHeaderComparator.java
deleted file mode 100644
index 02506d7a94..0000000000
--- a/briar-android/src/net/sf/briar/android/DescendingHeaderComparator.java
+++ /dev/null
@@ -1,19 +0,0 @@
-package net.sf.briar.android;
-
-import java.util.Comparator;
-
-import net.sf.briar.api.db.MessageHeader;
-
-public class DescendingHeaderComparator implements Comparator<MessageHeader> {
-
-	public static final DescendingHeaderComparator INSTANCE =
-			new DescendingHeaderComparator();
-
-	public int compare(MessageHeader a, MessageHeader b) {
-		// The newest message comes first
-		long aTime = a.getTimestamp(), bTime = b.getTimestamp();
-		if(aTime > bTime) return -1;
-		if(aTime < bTime) return 1;
-		return 0;
-	}
-}
\ No newline at end of file
diff --git a/briar-android/src/net/sf/briar/android/SetupActivity.java b/briar-android/src/net/sf/briar/android/SetupActivity.java
index 699aa16bc0..931ef24201 100644
--- a/briar-android/src/net/sf/briar/android/SetupActivity.java
+++ b/briar-android/src/net/sf/briar/android/SetupActivity.java
@@ -12,7 +12,6 @@ import static android.widget.LinearLayout.VERTICAL;
 import static net.sf.briar.android.util.CommonLayoutParams.MATCH_MATCH;
 import static net.sf.briar.android.util.CommonLayoutParams.WRAP_WRAP;
 
-import java.io.IOException;
 import java.util.Arrays;
 import java.util.concurrent.Executor;
 
@@ -188,13 +187,8 @@ public class SetupActivity extends RoboActivity implements OnClickListener {
 				KeyPair keyPair = crypto.generateSignatureKeyPair();
 				final byte[] publicKey = keyPair.getPublic().getEncoded();
 				final byte[] privateKey = keyPair.getPrivate().getEncoded();
-				LocalAuthor a;
-				try {
-					a = authorFactory.createLocalAuthor(nickname, publicKey,
-							privateKey);
-				} catch(IOException e) {
-					throw new RuntimeException(e);
-				}
+				LocalAuthor a = authorFactory.createLocalAuthor(nickname,
+						publicKey, privateKey);
 				showHomeScreen(referenceManager.putReference(a,
 						LocalAuthor.class));				
 			}
diff --git a/briar-android/src/net/sf/briar/android/contact/ContactListActivity.java b/briar-android/src/net/sf/briar/android/contact/ContactListActivity.java
index aad72c171e..14e6193999 100644
--- a/briar-android/src/net/sf/briar/android/contact/ContactListActivity.java
+++ b/briar-android/src/net/sf/briar/android/contact/ContactListActivity.java
@@ -24,7 +24,6 @@ import java.util.logging.Logger;
 import javax.inject.Inject;
 
 import net.sf.briar.R;
-import net.sf.briar.android.groups.NoContactsDialog;
 import net.sf.briar.android.invitation.AddContactActivity;
 import net.sf.briar.android.util.HorizontalBorder;
 import net.sf.briar.android.util.HorizontalSpace;
@@ -34,32 +33,29 @@ import net.sf.briar.api.ContactId;
 import net.sf.briar.api.android.DatabaseUiExecutor;
 import net.sf.briar.api.db.DatabaseComponent;
 import net.sf.briar.api.db.DbException;
+import net.sf.briar.api.db.MessageHeader;
 import net.sf.briar.api.db.NoSuchContactException;
-import net.sf.briar.api.db.PrivateMessageHeader;
 import net.sf.briar.api.db.event.ContactAddedEvent;
 import net.sf.briar.api.db.event.ContactRemovedEvent;
 import net.sf.briar.api.db.event.DatabaseEvent;
 import net.sf.briar.api.db.event.DatabaseListener;
+import net.sf.briar.api.db.event.MessageAddedEvent;
 import net.sf.briar.api.db.event.MessageExpiredEvent;
-import net.sf.briar.api.db.event.PrivateMessageAddedEvent;
 import net.sf.briar.api.lifecycle.LifecycleManager;
 import net.sf.briar.api.transport.ConnectionListener;
 import net.sf.briar.api.transport.ConnectionRegistry;
-import roboguice.activity.RoboFragmentActivity;
+import roboguice.activity.RoboActivity;
 import android.content.Intent;
 import android.net.Uri;
 import android.os.Bundle;
-import android.support.v4.app.Fragment;
-import android.support.v4.app.FragmentManager;
 import android.view.View;
 import android.view.View.OnClickListener;
 import android.widget.ImageButton;
 import android.widget.LinearLayout;
 import android.widget.ListView;
 
-public class ContactListActivity extends RoboFragmentActivity
-implements OnClickListener, DatabaseListener, ConnectionListener,
-NoContactsDialog.Listener {
+public class ContactListActivity extends RoboActivity
+implements OnClickListener, DatabaseListener, ConnectionListener {
 
 	private static final Logger LOG =
 			Logger.getLogger(ContactListActivity.class.getName());
@@ -68,9 +64,7 @@ NoContactsDialog.Listener {
 	private ContactListAdapter adapter = null;
 	private ListView list = null;
 	private ListLoadingProgressBar loading = null;
-	private ImageButton addContactButton = null, composeButton = null;
-	private ImageButton shareButton = null;
-	private NoContactsDialog noContactsDialog = null;
+	private ImageButton addContactButton = null, shareButton = null;
 
 	// Fields that are accessed from background threads must be volatile
 	@Inject private volatile DatabaseComponent db;
@@ -113,13 +107,6 @@ NoContactsDialog.Listener {
 		footer.addView(addContactButton);
 		footer.addView(new HorizontalSpace(this));
 
-		composeButton = new ImageButton(this);
-		composeButton.setBackgroundResource(0);
-		composeButton.setImageResource(R.drawable.content_new_email);
-		composeButton.setOnClickListener(this);
-		footer.addView(composeButton);
-		footer.addView(new HorizontalSpace(this));
-
 		shareButton = new ImageButton(this);
 		shareButton.setBackgroundResource(0);
 		shareButton.setImageResource(R.drawable.social_share);
@@ -129,12 +116,6 @@ NoContactsDialog.Listener {
 		layout.addView(footer);
 
 		setContentView(layout);
-
-		FragmentManager fm = getSupportFragmentManager();
-		Fragment f = fm.findFragmentByTag("NoContactsDialog");
-		if(f == null) noContactsDialog = new NoContactsDialog();
-		else noContactsDialog = (NoContactsDialog) f;
-		noContactsDialog.setListener(this);
 	}
 
 	@Override
@@ -142,11 +123,11 @@ NoContactsDialog.Listener {
 		super.onResume();
 		db.addListener(this);
 		connectionRegistry.addListener(this);
-		loadHeaders();
+		loadContacts();
 	}
 
-	private void loadHeaders() {
-		clearHeaders();
+	private void loadContacts() {
+		clearContacts();
 		dbUiExecutor.execute(new Runnable() {
 			public void run() {
 				try {
@@ -157,9 +138,9 @@ NoContactsDialog.Listener {
 						Long lastConnected = times.get(c.getId());
 						if(lastConnected == null) continue;
 						try {
-							Collection<PrivateMessageHeader> headers =
-									db.getPrivateMessageHeaders(c.getId());
-							displayHeaders(c, lastConnected, headers);
+							Collection<MessageHeader> headers =
+									db.getInboxMessageHeaders(c.getId());
+							displayContact(c, lastConnected, headers);
 						} catch(NoSuchContactException e) {
 							if(LOG.isLoggable(INFO))
 								LOG.info("Contact removed");
@@ -181,7 +162,7 @@ NoContactsDialog.Listener {
 		});
 	}
 
-	private void clearHeaders() {
+	private void clearContacts() {
 		runOnUiThread(new Runnable() {
 			public void run() {
 				list.setVisibility(GONE);
@@ -192,8 +173,8 @@ NoContactsDialog.Listener {
 		});
 	}
 
-	private void displayHeaders(final Contact c, final long lastConnected,
-			final Collection<PrivateMessageHeader> headers) {
+	private void displayContact(final Contact c, final long lastConnected,
+			final Collection<MessageHeader> headers) {
 		runOnUiThread(new Runnable() {
 			public void run() {
 				list.setVisibility(VISIBLE);
@@ -239,14 +220,6 @@ NoContactsDialog.Listener {
 	public void onClick(View view) {
 		if(view == addContactButton) {
 			startActivity(new Intent(this, AddContactActivity.class));
-		} else if(view == composeButton) {
-			if(adapter.isEmpty()) {
-				FragmentManager fm = getSupportFragmentManager();
-				noContactsDialog.show(fm, "NoContactsDialog");
-			} else {
-				startActivity(new Intent(this,
-						WritePrivateMessageActivity.class));
-			}
 		} else if(view == shareButton) {
 			String apkPath = getPackageCodePath();
 			Intent i = new Intent(ACTION_SEND);
@@ -259,28 +232,28 @@ NoContactsDialog.Listener {
 
 	public void eventOccurred(DatabaseEvent e) {
 		if(e instanceof ContactAddedEvent) {
-			loadHeaders();
+			loadContacts();
 		} else if(e instanceof ContactRemovedEvent) {
 			// Reload the conversation, expecting NoSuchContactException
 			if(LOG.isLoggable(INFO)) LOG.info("Contact removed, reloading");
-			reloadHeaders(((ContactRemovedEvent) e).getContactId());
+			reloadContact(((ContactRemovedEvent) e).getContactId());
+		} else if(e instanceof MessageAddedEvent) {
+			if(LOG.isLoggable(INFO)) LOG.info("Message added, reloading");
+			reloadContact(((MessageAddedEvent) e).getContactId());
 		} else if(e instanceof MessageExpiredEvent) {
 			if(LOG.isLoggable(INFO)) LOG.info("Message expired, reloading");
-			loadHeaders();
-		} else if(e instanceof PrivateMessageAddedEvent) {
-			if(LOG.isLoggable(INFO)) LOG.info("Message added, reloading");
-			reloadHeaders(((PrivateMessageAddedEvent) e).getContactId());
+			loadContacts();
 		}
 	}
 
-	private void reloadHeaders(final ContactId c) {
+	private void reloadContact(final ContactId c) {
 		dbUiExecutor.execute(new Runnable() {
 			public void run() {
 				try {
 					lifecycleManager.waitForDatabase();
 					long now = System.currentTimeMillis();
-					Collection<PrivateMessageHeader> headers =
-							db.getPrivateMessageHeaders(c);
+					Collection<MessageHeader> headers =
+							db.getInboxMessageHeaders(c);
 					long duration = System.currentTimeMillis() - now;
 					if(LOG.isLoggable(INFO))
 						LOG.info("Partial load took " + duration + " ms");
@@ -301,18 +274,11 @@ NoContactsDialog.Listener {
 	}
 
 	private void updateItem(final ContactId c,
-			final Collection<PrivateMessageHeader> headers) {
+			final Collection<MessageHeader> headers) {
 		runOnUiThread(new Runnable() {
 			public void run() {
 				ContactListItem item = findItem(c);
-				if(item == null) return;
-				// Replace the item with a new item containing the new headers
-				adapter.remove(item);
-				item = new ContactListItem(item.getContact(),
-						item.isConnected(), item.getLastConnected(), headers);
-				adapter.add(item);
-				adapter.sort(ItemComparator.INSTANCE);
-				adapter.notifyDataSetChanged();
+				if(item != null) item.setHeaders(headers);
 			}
 		});
 	}
@@ -337,28 +303,16 @@ NoContactsDialog.Listener {
 	private void setConnected(final ContactId c, final boolean connected) {
 		runOnUiThread(new Runnable() {
 			public void run() {
-				int count = adapter.getCount();
-				for(int i = 0; i < count; i++) {
-					ContactListItem item = adapter.getItem(i);
-					if(item.getContactId().equals(c)) {
-						if(LOG.isLoggable(INFO))
-							LOG.info("Updating connection time");
-						item.setConnected(connected);
-						item.setLastConnected(System.currentTimeMillis());
-						list.invalidateViews();
-						return;
-					}
-				}
+				ContactListItem item = findItem(c);
+				if(item == null) return;
+				if(LOG.isLoggable(INFO)) LOG.info("Updating connection time");
+				item.setConnected(connected);
+				item.setLastConnected(System.currentTimeMillis());
+				list.invalidateViews();
 			}
 		});
 	}
 
-	public void contactCreationSelected() {
-		startActivity(new Intent(this, AddContactActivity.class));
-	}
-
-	public void contactCreationCancelled() {}
-
 	private static class ItemComparator implements Comparator<ContactListItem> {
 
 		private static final ItemComparator INSTANCE = new ItemComparator();
diff --git a/briar-android/src/net/sf/briar/android/contact/ContactListItem.java b/briar-android/src/net/sf/briar/android/contact/ContactListItem.java
index 5787c11588..f075db23af 100644
--- a/briar-android/src/net/sf/briar/android/contact/ContactListItem.java
+++ b/briar-android/src/net/sf/briar/android/contact/ContactListItem.java
@@ -1,15 +1,11 @@
 package net.sf.briar.android.contact;
 
-import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
 
-import net.sf.briar.android.DescendingHeaderComparator;
 import net.sf.briar.api.AuthorId;
 import net.sf.briar.api.Contact;
 import net.sf.briar.api.ContactId;
-import net.sf.briar.api.db.PrivateMessageHeader;
+import net.sf.briar.api.db.MessageHeader;
 
 // This class is not thread-safe
 class ContactListItem {
@@ -17,27 +13,27 @@ class ContactListItem {
 	private final Contact contact;
 	private boolean connected;
 	private long lastConnected;
-	private final boolean empty;
-	private final long timestamp;
-	private final int unread;
+	private boolean empty;
+	private long timestamp;
+	private int unread;
 
 	ContactListItem(Contact contact, boolean connected, long lastConnected,
-			Collection<PrivateMessageHeader> headers) {
+			Collection<MessageHeader> headers) {
 		this.contact = contact;
 		this.connected = connected;
 		this.lastConnected = lastConnected;
+		setHeaders(headers);
+	}
+
+	void setHeaders(Collection<MessageHeader> headers) {
 		empty = headers.isEmpty();
-		if(empty) {
-			timestamp = 0;
-			unread = 0;
-		} else {
-			List<PrivateMessageHeader> list =
-					new ArrayList<PrivateMessageHeader>(headers);
-			Collections.sort(list, DescendingHeaderComparator.INSTANCE);
-			timestamp = list.get(0).getTimestamp();
-			int unread = 0;
-			for(PrivateMessageHeader h : list) if(!h.isRead()) unread++;
-			this.unread = unread;
+		timestamp = 0;
+		unread = 0;
+		if(!empty) {
+			for(MessageHeader h : headers) {
+				if(h.getTimestamp() > timestamp) timestamp = h.getTimestamp();
+				if(!h.isRead()) unread++;
+			}
 		}
 	}
 
diff --git a/briar-android/src/net/sf/briar/android/contact/ContactSpinnerAdapter.java b/briar-android/src/net/sf/briar/android/contact/ContactSpinnerAdapter.java
deleted file mode 100644
index 58e17c02d2..0000000000
--- a/briar-android/src/net/sf/briar/android/contact/ContactSpinnerAdapter.java
+++ /dev/null
@@ -1,77 +0,0 @@
-package net.sf.briar.android.contact;
-
-import static net.sf.briar.android.contact.ContactItem.NEW;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-
-import net.sf.briar.R;
-import android.content.Context;
-import android.content.res.Resources;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.BaseAdapter;
-import android.widget.SpinnerAdapter;
-import android.widget.TextView;
-
-public class ContactSpinnerAdapter extends BaseAdapter
-implements SpinnerAdapter {
-
-	private final Context ctx;
-	private final List<ContactItem> list = new ArrayList<ContactItem>();
-
-	public ContactSpinnerAdapter(Context ctx) {
-		this.ctx = ctx;
-	}
-
-	public void add(ContactItem item) {
-		list.add(item);
-	}
-
-	public void clear() {
-		list.clear();
-	}
-
-	public int getCount() {
-		return list.isEmpty() ? 0 : list.size() + 1;
-	}
-
-	@Override
-	public View getDropDownView(int position, View convertView,
-			ViewGroup parent) {
-		return getView(position, convertView, parent);
-	}
-
-	public ContactItem getItem(int position) {
-		if(position == list.size()) return NEW;
-		return list.get(position);
-	}
-
-	public long getItemId(int position) {
-		return android.R.layout.simple_spinner_item;
-	}
-
-	public View getView(int position, View convertView, ViewGroup parent) {
-		TextView name = new TextView(ctx);
-		name.setTextSize(18);
-		name.setMaxLines(1);
-		Resources res = ctx.getResources();
-		int pad = res.getInteger(R.integer.spinner_padding);
-		name.setPadding(pad, pad, pad, pad);
-		ContactItem item = getItem(position);
-		if(item == NEW) name.setText(R.string.new_contact_item);
-		else name.setText(item.getContact().getAuthor().getName());
-		return name;
-	}
-
-	@Override
-	public boolean isEmpty() {
-		return list.isEmpty();
-	}
-
-	public void sort(Comparator<ContactItem> comparator) {
-		Collections.sort(list, comparator);
-	}
-}
diff --git a/briar-android/src/net/sf/briar/android/contact/ConversationActivity.java b/briar-android/src/net/sf/briar/android/contact/ConversationActivity.java
index a0fe1020c1..30a5502a9d 100644
--- a/briar-android/src/net/sf/briar/android/contact/ConversationActivity.java
+++ b/briar-android/src/net/sf/briar/android/contact/ConversationActivity.java
@@ -16,22 +16,21 @@ import java.util.logging.Logger;
 import javax.inject.Inject;
 
 import net.sf.briar.R;
-import net.sf.briar.android.AscendingHeaderComparator;
 import net.sf.briar.android.util.HorizontalBorder;
 import net.sf.briar.android.util.ListLoadingProgressBar;
-import net.sf.briar.api.AuthorId;
 import net.sf.briar.api.ContactId;
 import net.sf.briar.api.android.DatabaseUiExecutor;
 import net.sf.briar.api.db.DatabaseComponent;
 import net.sf.briar.api.db.DbException;
+import net.sf.briar.api.db.MessageHeader;
 import net.sf.briar.api.db.NoSuchContactException;
-import net.sf.briar.api.db.PrivateMessageHeader;
 import net.sf.briar.api.db.event.ContactRemovedEvent;
 import net.sf.briar.api.db.event.DatabaseEvent;
 import net.sf.briar.api.db.event.DatabaseListener;
+import net.sf.briar.api.db.event.MessageAddedEvent;
 import net.sf.briar.api.db.event.MessageExpiredEvent;
-import net.sf.briar.api.db.event.PrivateMessageAddedEvent;
 import net.sf.briar.api.lifecycle.LifecycleManager;
+import net.sf.briar.api.messaging.GroupId;
 import roboguice.activity.RoboActivity;
 import android.content.Intent;
 import android.os.Bundle;
@@ -53,13 +52,14 @@ implements DatabaseListener, OnClickListener, OnItemClickListener {
 	private ConversationAdapter adapter = null;
 	private ListView list = null;
 	private ListLoadingProgressBar loading = null;
+	private ImageButton composeButton = null;
 
 	// Fields that are accessed from background threads must be volatile
 	@Inject private volatile DatabaseComponent db;
 	@Inject @DatabaseUiExecutor private volatile Executor dbUiExecutor;
 	@Inject private volatile LifecycleManager lifecycleManager;
 	private volatile ContactId contactId = null;
-	private volatile AuthorId localAuthorId = null;
+	private volatile GroupId groupId = null;
 
 	@Override
 	public void onCreate(Bundle state) {
@@ -72,9 +72,6 @@ implements DatabaseListener, OnClickListener, OnItemClickListener {
 		contactName = i.getStringExtra("net.sf.briar.CONTACT_NAME");
 		if(contactName == null) throw new IllegalStateException();
 		setTitle(contactName);
-		byte[] b = i.getByteArrayExtra("net.sf.briar.LOCAL_AUTHOR_ID");
-		if(b == null) throw new IllegalStateException();
-		localAuthorId = new AuthorId(b);
 
 		LinearLayout layout = new LinearLayout(this);
 		layout.setLayoutParams(MATCH_MATCH);
@@ -96,9 +93,10 @@ implements DatabaseListener, OnClickListener, OnItemClickListener {
 
 		layout.addView(new HorizontalBorder(this));
 
-		ImageButton composeButton = new ImageButton(this);
+		composeButton = new ImageButton(this);
 		composeButton.setBackgroundResource(0);
 		composeButton.setImageResource(R.drawable.content_new_email);
+		composeButton.setEnabled(false); // Enabled after loading the headers
 		composeButton.setOnClickListener(this);
 		layout.addView(composeButton);
 
@@ -118,8 +116,9 @@ implements DatabaseListener, OnClickListener, OnItemClickListener {
 				try {
 					lifecycleManager.waitForDatabase();
 					long now = System.currentTimeMillis();
-					Collection<PrivateMessageHeader> headers =
-							db.getPrivateMessageHeaders(contactId);
+					groupId = db.getInboxGroup(contactId);
+					Collection<MessageHeader> headers =
+							db.getInboxMessageHeaders(contactId);
 					long duration = System.currentTimeMillis() - now;
 					if(LOG.isLoggable(INFO))
 						LOG.info("Load took " + duration + " ms");
@@ -143,15 +142,16 @@ implements DatabaseListener, OnClickListener, OnItemClickListener {
 		});
 	}
 
-	private void displayHeaders(
-			final Collection<PrivateMessageHeader> headers) {
+	private void displayHeaders(final Collection<MessageHeader> headers) {
 		runOnUiThread(new Runnable() {
 			public void run() {
 				list.setVisibility(VISIBLE);
 				loading.setVisibility(GONE);
+				composeButton.setEnabled(true);
 				adapter.clear();
-				for(PrivateMessageHeader h : headers) adapter.add(h);
-				adapter.sort(AscendingHeaderComparator.INSTANCE);
+				for(MessageHeader h : headers)
+					adapter.add(new ConversationItem(h));
+				adapter.sort(ConversationItemComparator.INSTANCE);
 				adapter.notifyDataSetChanged();
 				selectFirstUnread();
 			}
@@ -161,7 +161,7 @@ implements DatabaseListener, OnClickListener, OnItemClickListener {
 	private void selectFirstUnread() {
 		int firstUnread = -1, count = adapter.getCount();
 		for(int i = 0; i < count; i++) {
-			if(!adapter.getItem(i).isRead()) {
+			if(!adapter.getItem(i).getHeader().isRead()) {
 				firstUnread = i;
 				break;
 			}
@@ -200,22 +200,21 @@ implements DatabaseListener, OnClickListener, OnItemClickListener {
 					}
 				});
 			}
-		} else if(e instanceof MessageExpiredEvent) {
-			if(LOG.isLoggable(INFO)) LOG.info("Message expired, reloading");
-			loadHeaders();
-		} else if(e instanceof PrivateMessageAddedEvent) {
-			PrivateMessageAddedEvent p = (PrivateMessageAddedEvent) e;
-			if(p.getContactId().equals(contactId)) {
+		} else if(e instanceof MessageAddedEvent) {
+			if(((MessageAddedEvent) e).getContactId().equals(contactId)) {
 				if(LOG.isLoggable(INFO)) LOG.info("Message added, reloading");
 				loadHeaders();
 			}
+		} else if(e instanceof MessageExpiredEvent) {
+			if(LOG.isLoggable(INFO)) LOG.info("Message expired, reloading");
+			loadHeaders();
 		}
 	}
 
 	public void onClick(View view) {
 		Intent i = new Intent(this, WritePrivateMessageActivity.class);
 		i.putExtra("net.sf.briar.CONTACT_ID", contactId.getInt());
-		i.putExtra("net.sf.briar.LOCAL_AUTHOR_ID", localAuthorId.getBytes());
+		i.putExtra("net.sf.briar.GROUP_ID", groupId.getBytes());
 		startActivity(i);
 	}
 
@@ -225,14 +224,15 @@ implements DatabaseListener, OnClickListener, OnItemClickListener {
 	}
 
 	private void displayMessage(int position) {
-		PrivateMessageHeader item = adapter.getItem(position);
+		MessageHeader header = adapter.getItem(position).getHeader();
 		Intent i = new Intent(this, ReadPrivateMessageActivity.class);
 		i.putExtra("net.sf.briar.CONTACT_ID", contactId.getInt());
 		i.putExtra("net.sf.briar.CONTACT_NAME", contactName);
-		i.putExtra("net.sf.briar.AUTHOR_NAME", item.getAuthor().getName());
-		i.putExtra("net.sf.briar.MESSAGE_ID", item.getId().getBytes());
-		i.putExtra("net.sf.briar.CONTENT_TYPE", item.getContentType());
-		i.putExtra("net.sf.briar.TIMESTAMP", item.getTimestamp());
+		i.putExtra("net.sf.briar.GROUP_ID", header.getGroupId().getBytes());
+		i.putExtra("net.sf.briar.AUTHOR_NAME", header.getAuthor().getName());
+		i.putExtra("net.sf.briar.MESSAGE_ID", header.getId().getBytes());
+		i.putExtra("net.sf.briar.CONTENT_TYPE", header.getContentType());
+		i.putExtra("net.sf.briar.TIMESTAMP", header.getTimestamp());
 		startActivityForResult(i, position);
 	}
 }
diff --git a/briar-android/src/net/sf/briar/android/contact/ConversationAdapter.java b/briar-android/src/net/sf/briar/android/contact/ConversationAdapter.java
index 3497efd103..d1a4d7d209 100644
--- a/briar-android/src/net/sf/briar/android/contact/ConversationAdapter.java
+++ b/briar-android/src/net/sf/briar/android/contact/ConversationAdapter.java
@@ -7,7 +7,7 @@ import static net.sf.briar.android.util.CommonLayoutParams.WRAP_WRAP_1;
 import java.util.ArrayList;
 
 import net.sf.briar.R;
-import net.sf.briar.api.db.PrivateMessageHeader;
+import net.sf.briar.api.db.MessageHeader;
 import android.content.Context;
 import android.content.res.Resources;
 import android.text.format.DateUtils;
@@ -17,21 +17,21 @@ import android.widget.ArrayAdapter;
 import android.widget.LinearLayout;
 import android.widget.TextView;
 
-class ConversationAdapter extends ArrayAdapter<PrivateMessageHeader> {
+class ConversationAdapter extends ArrayAdapter<ConversationItem> {
 
 	ConversationAdapter(Context ctx) {
 		super(ctx, android.R.layout.simple_expandable_list_item_1,
-				new ArrayList<PrivateMessageHeader>());
+				new ArrayList<ConversationItem>());
 	}
 
 	@Override
 	public View getView(int position, View convertView, ViewGroup parent) {
-		PrivateMessageHeader item = getItem(position);
+		MessageHeader header = getItem(position).getHeader();
 		Context ctx = getContext();
 
 		LinearLayout layout = new LinearLayout(ctx);
 		layout.setOrientation(HORIZONTAL);
-		if(!item.isRead()) {
+		if(!header.isRead()) {
 			Resources res = ctx.getResources();
 			layout.setBackgroundColor(res.getColor(R.color.unread_background));
 		}
@@ -42,13 +42,13 @@ class ConversationAdapter extends ArrayAdapter<PrivateMessageHeader> {
 		name.setTextSize(18);
 		name.setMaxLines(1);
 		name.setPadding(10, 10, 10, 10);
-		name.setText(item.getAuthor().getName());
+		name.setText(header.getAuthor().getName());
 		layout.addView(name);
 
 		TextView date = new TextView(ctx);
 		date.setTextSize(14);
 		date.setPadding(0, 10, 10, 10);
-		long then = item.getTimestamp(), now = System.currentTimeMillis();
+		long then = header.getTimestamp(), now = System.currentTimeMillis();
 		date.setText(DateUtils.formatSameDayTime(then, now, SHORT, SHORT));
 		layout.addView(date);
 
diff --git a/briar-android/src/net/sf/briar/android/contact/ConversationItem.java b/briar-android/src/net/sf/briar/android/contact/ConversationItem.java
new file mode 100644
index 0000000000..a8e777eba1
--- /dev/null
+++ b/briar-android/src/net/sf/briar/android/contact/ConversationItem.java
@@ -0,0 +1,37 @@
+package net.sf.briar.android.contact;
+
+import net.sf.briar.api.db.MessageHeader;
+
+// This class is not thread-safe
+class ConversationItem {
+
+	private final MessageHeader header;
+	private boolean expanded;
+	private byte[] body;
+
+	ConversationItem(MessageHeader header) {
+		this.header = header;
+		expanded = false;
+		body = null;
+	}
+
+	MessageHeader getHeader() {
+		return header;
+	}
+
+	boolean isExpanded() {
+		return expanded;
+	}
+
+	void setExpanded(boolean expanded) {
+		this.expanded = expanded;
+	}
+
+	byte[] getBody() {
+		return body;
+	}
+
+	void setBody(byte[] body) {
+		this.body = body;
+	}
+}
diff --git a/briar-android/src/net/sf/briar/android/contact/ConversationItemComparator.java b/briar-android/src/net/sf/briar/android/contact/ConversationItemComparator.java
new file mode 100644
index 0000000000..bdeca9fb55
--- /dev/null
+++ b/briar-android/src/net/sf/briar/android/contact/ConversationItemComparator.java
@@ -0,0 +1,19 @@
+package net.sf.briar.android.contact;
+
+import java.util.Comparator;
+
+public class ConversationItemComparator
+implements Comparator<ConversationItem> {
+
+	public static final ConversationItemComparator INSTANCE =
+			new ConversationItemComparator();
+
+	public int compare(ConversationItem a, ConversationItem b) {
+		// The oldest message comes first
+		long aTime = a.getHeader().getTimestamp();
+		long bTime = b.getHeader().getTimestamp();
+		if(aTime < bTime) return -1;
+		if(aTime > bTime) return 1;
+		return 0;
+	}
+}
diff --git a/briar-android/src/net/sf/briar/android/contact/ReadPrivateMessageActivity.java b/briar-android/src/net/sf/briar/android/contact/ReadPrivateMessageActivity.java
index 64927e0c69..6c970f4bbf 100644
--- a/briar-android/src/net/sf/briar/android/contact/ReadPrivateMessageActivity.java
+++ b/briar-android/src/net/sf/briar/android/contact/ReadPrivateMessageActivity.java
@@ -26,6 +26,7 @@ import net.sf.briar.api.db.DatabaseComponent;
 import net.sf.briar.api.db.DbException;
 import net.sf.briar.api.db.NoSuchMessageException;
 import net.sf.briar.api.lifecycle.LifecycleManager;
+import net.sf.briar.api.messaging.GroupId;
 import net.sf.briar.api.messaging.MessageId;
 import roboguice.activity.RoboActivity;
 import android.content.Intent;
@@ -60,6 +61,7 @@ implements OnClickListener {
 	@Inject @DatabaseUiExecutor private volatile Executor dbUiExecutor;
 	@Inject private volatile LifecycleManager lifecycleManager;
 	private volatile MessageId messageId = null;
+	private volatile GroupId groupId = null;
 	private volatile long timestamp = -1;
 
 	@Override
@@ -78,6 +80,9 @@ implements OnClickListener {
 		byte[] b = i.getByteArrayExtra("net.sf.briar.MESSAGE_ID");
 		if(b == null) throw new IllegalStateException();
 		messageId = new MessageId(b);
+		b = i.getByteArrayExtra("net.sf.briar.GROUP_ID");
+		if(b == null) throw new IllegalStateException();
+		groupId = new GroupId(b);
 		String contentType = i.getStringExtra("net.sf.briar.CONTENT_TYPE");
 		if(contentType == null) throw new IllegalStateException();
 		timestamp = i.getLongExtra("net.sf.briar.TIMESTAMP", -1);
@@ -262,6 +267,7 @@ implements OnClickListener {
 		} else if(view == replyButton) {
 			Intent i = new Intent(this, WritePrivateMessageActivity.class);
 			i.putExtra("net.sf.briar.CONTACT_ID", contactId.getInt());
+			i.putExtra("net.sf.briar.GROUP_ID", groupId.getBytes());
 			i.putExtra("net.sf.briar.PARENT_ID", messageId.getBytes());
 			i.putExtra("net.sf.briar.TIMESTAMP", timestamp);
 			startActivity(i);
diff --git a/briar-android/src/net/sf/briar/android/contact/WritePrivateMessageActivity.java b/briar-android/src/net/sf/briar/android/contact/WritePrivateMessageActivity.java
index 38891115c5..a0b110c03b 100644
--- a/briar-android/src/net/sf/briar/android/contact/WritePrivateMessageActivity.java
+++ b/briar-android/src/net/sf/briar/android/contact/WritePrivateMessageActivity.java
@@ -12,49 +12,46 @@ import static net.sf.briar.android.util.CommonLayoutParams.MATCH_WRAP;
 import java.io.IOException;
 import java.io.UnsupportedEncodingException;
 import java.security.GeneralSecurityException;
-import java.util.Collection;
 import java.util.concurrent.Executor;
 import java.util.logging.Logger;
 
 import javax.inject.Inject;
 
 import net.sf.briar.R;
-import net.sf.briar.android.invitation.AddContactActivity;
 import net.sf.briar.android.util.HorizontalSpace;
-import net.sf.briar.api.AuthorId;
 import net.sf.briar.api.Contact;
 import net.sf.briar.api.ContactId;
 import net.sf.briar.api.LocalAuthor;
 import net.sf.briar.api.android.DatabaseUiExecutor;
 import net.sf.briar.api.db.DatabaseComponent;
 import net.sf.briar.api.db.DbException;
+import net.sf.briar.api.db.NoSuchContactException;
+import net.sf.briar.api.db.NoSuchSubscriptionException;
 import net.sf.briar.api.lifecycle.LifecycleManager;
+import net.sf.briar.api.messaging.Group;
+import net.sf.briar.api.messaging.GroupId;
 import net.sf.briar.api.messaging.Message;
 import net.sf.briar.api.messaging.MessageFactory;
 import net.sf.briar.api.messaging.MessageId;
 import roboguice.activity.RoboActivity;
 import android.content.Intent;
+import android.content.res.Resources;
 import android.os.Bundle;
 import android.text.InputType;
 import android.view.View;
 import android.view.View.OnClickListener;
-import android.widget.AdapterView;
-import android.widget.AdapterView.OnItemSelectedListener;
 import android.widget.EditText;
 import android.widget.ImageButton;
 import android.widget.LinearLayout;
-import android.widget.Spinner;
 import android.widget.TextView;
 
 public class WritePrivateMessageActivity extends RoboActivity
-implements OnItemSelectedListener, OnClickListener {
+implements OnClickListener {
 
 	private static final Logger LOG =
 			Logger.getLogger(WritePrivateMessageActivity.class.getName());
 
-	private TextView from = null;
-	private ContactSpinnerAdapter adapter = null;
-	private Spinner spinner = null;
+	private TextView from = null, to = null;
 	private ImageButton sendButton = null;
 	private EditText content = null;
 
@@ -63,10 +60,13 @@ implements OnItemSelectedListener, OnClickListener {
 	@Inject @DatabaseUiExecutor private volatile Executor dbUiExecutor;
 	@Inject private volatile LifecycleManager lifecycleManager;
 	@Inject private volatile MessageFactory messageFactory;
-	private volatile LocalAuthor localAuthor = null;
 	private volatile ContactId contactId = null;
+	private volatile GroupId groupId = null;
 	private volatile MessageId parentId = null;
 	private volatile long timestamp = -1;
+	private volatile Contact contact = null;
+	private volatile LocalAuthor localAuthor = null;
+	private volatile Group group = null;
 
 	@Override
 	public void onCreate(Bundle state) {
@@ -74,16 +74,15 @@ implements OnItemSelectedListener, OnClickListener {
 
 		Intent i = getIntent();
 		int id = i.getIntExtra("net.sf.briar.CONTACT_ID", -1);
-		if(id != -1) contactId = new ContactId(id);
-		byte[] b = i.getByteArrayExtra("net.sf.briar.PARENT_ID");
+		if(id == -1) throw new IllegalStateException();
+		contactId = new ContactId(id);
+		byte[] b = i.getByteArrayExtra("net.sf.briar.GROUP_ID");
+		if(b == null) throw new IllegalStateException();
+		groupId = new GroupId(b);
+		b = i.getByteArrayExtra("net.sf.briar.PARENT_ID");
 		if(b != null) parentId = new MessageId(b);
 		timestamp = i.getLongExtra("net.sf.briar.TIMESTAMP", -1);
 
-		if(state != null) {
-			id = state.getInt("net.sf.briar.CONTACT_ID", -1);
-			if(id != -1) contactId = new ContactId(id);
-		}
-
 		LinearLayout layout = new LinearLayout(this);
 		layout.setLayoutParams(MATCH_WRAP);
 		layout.setOrientation(VERTICAL);
@@ -104,28 +103,16 @@ implements OnItemSelectedListener, OnClickListener {
 		sendButton = new ImageButton(this);
 		sendButton.setBackgroundResource(0);
 		sendButton.setImageResource(R.drawable.social_send_now);
-		sendButton.setEnabled(false); // Enabled after loading the local author
+		sendButton.setEnabled(false); // Enabled after loading the group
 		sendButton.setOnClickListener(this);
 		header.addView(sendButton);
 		layout.addView(header);
 
-		header = new LinearLayout(this);
-		header.setLayoutParams(MATCH_WRAP);
-		header.setOrientation(HORIZONTAL);
-		header.setGravity(CENTER_VERTICAL);
-
-		TextView to = new TextView(this);
+		to = new TextView(this);
 		to.setTextSize(18);
-		to.setPadding(10, 0, 0, 10);
+		to.setPadding(10, 10, 10, 10);
 		to.setText(R.string.to);
-		header.addView(to);
-
-		adapter = new ContactSpinnerAdapter(this);
-		spinner = new Spinner(this);
-		spinner.setAdapter(adapter);
-		spinner.setOnItemSelectedListener(this);
-		header.addView(spinner);
-		layout.addView(header);
+		layout.addView(to);
 
 		content = new EditText(this);
 		content.setId(1);
@@ -140,20 +127,26 @@ implements OnItemSelectedListener, OnClickListener {
 	@Override
 	public void onResume() {
 		super.onResume();
-		loadContacts();
+		loadAuthorsAndGroup();
 	}
 
-	private void loadContacts() {
+	private void loadAuthorsAndGroup() {
 		dbUiExecutor.execute(new Runnable() {
 			public void run() {
 				try {
 					lifecycleManager.waitForDatabase();
 					long now = System.currentTimeMillis();
-					Collection<Contact> contacts = db.getContacts();
+					contact = db.getContact(contactId);
+					localAuthor = db.getLocalAuthor(contact.getLocalAuthorId());
+					group = db.getGroup(groupId);
 					long duration = System.currentTimeMillis() - now;
 					if(LOG.isLoggable(INFO))
-						LOG.info("Loading contacts took " + duration + " ms");
-					displayContacts(contacts);
+						LOG.info("Load took " + duration + " ms");
+					displayAuthors();
+				} catch(NoSuchContactException e) {
+					finish();
+				} catch(NoSuchSubscriptionException e) {
+					finish();
 				} catch(DbException e) {
 					if(LOG.isLoggable(WARNING))
 						LOG.log(WARNING, e.toString(), e);
@@ -165,101 +158,33 @@ implements OnItemSelectedListener, OnClickListener {
 		});
 	}
 
-	private void displayContacts(final Collection<Contact> contacts) {
+	private void displayAuthors() {
 		runOnUiThread(new Runnable() {
 			public void run() {
-				if(contacts.isEmpty()) finish();
-				adapter.clear();
-				for(Contact c : contacts) adapter.add(new ContactItem(c));
-				adapter.sort(ContactItemComparator.INSTANCE);
-				adapter.notifyDataSetChanged();
-				int count = adapter.getCount();
-				for(int i = 0; i < count; i++) {
-					ContactItem item = adapter.getItem(i);
-					if(item == ContactItem.NEW) continue;
-					if(item.getContact().getId().equals(contactId)) {
-						spinner.setSelection(i);
-						break;
-					}
-				}
-			}
-		});
-	}
-
-	@Override
-	public void onSaveInstanceState(Bundle state) {
-		super.onSaveInstanceState(state);
-		if(contactId != null)
-			state.putInt("net.sf.briar.CONTACT_ID", contactId.getInt());
-	}
-
-	public void onItemSelected(AdapterView<?> parent, View view, int position,
-			long id) {
-		ContactItem item = adapter.getItem(position);
-		if(item == ContactItem.NEW) {
-			contactId = null;
-			localAuthor = null;
-			startActivity(new Intent(this, AddContactActivity.class));
-		} else {
-			Contact c = item.getContact();
-			contactId = c.getId();
-			localAuthor = null;
-			loadLocalAuthor(c.getLocalAuthorId());
-		}
-		sendButton.setEnabled(false);
-	}
-
-	private void loadLocalAuthor(final AuthorId a) {
-		dbUiExecutor.execute(new Runnable() {
-			public void run() {
-				try {
-					lifecycleManager.waitForDatabase();
-					long now = System.currentTimeMillis();
-					localAuthor = db.getLocalAuthor(a);
-					long duration = System.currentTimeMillis() - now;
-					if(LOG.isLoggable(INFO))
-						LOG.info("Loading author took " + duration + " ms");
-					displayLocalAuthor();
-				} catch(DbException e) {
-					if(LOG.isLoggable(WARNING))
-						LOG.log(WARNING, e.toString(), e);
-				} catch(InterruptedException e) {
-					LOG.info("Interrupted while waiting for database");
-					Thread.currentThread().interrupt();
-				}
-			}
-		});
-	}
-
-	private void displayLocalAuthor() {
-		runOnUiThread(new Runnable() {
-			public void run() {
-				String format = getResources().getString(R.string.format_from);
-				from.setText(String.format(format, localAuthor.getName()));
+				Resources res = getResources();
+				String format = res.getString(R.string.format_from);
+				String name = localAuthor.getName();
+				from.setText(String.format(format, name));
+				format = res.getString(R.string.format_to);
+				name = contact.getAuthor().getName();
+				to.setText(String.format(format, name));
 				sendButton.setEnabled(true);
 			}
 		});
 	}
 
-	public void onNothingSelected(AdapterView<?> parent) {
-		contactId = null;
-		sendButton.setEnabled(false);
-	}
-
 	public void onClick(View view) {
-		if(localAuthor == null || contactId == null)
+		if(contact == null || localAuthor == null)
 			throw new IllegalStateException();
 		try {
-			byte[] b = content.getText().toString().getBytes("UTF-8");
-			storeMessage(localAuthor, contactId, b);
+			storeMessage(content.getText().toString().getBytes("UTF-8"));
 		} catch(UnsupportedEncodingException e) {
 			throw new RuntimeException(e);
 		}
 		finish();
 	}
 
-	private void storeMessage(final LocalAuthor localAuthor,
-			final ContactId contactId, final byte[] body) {
+	private void storeMessage(final byte[] body) {
 		dbUiExecutor.execute(new Runnable() {
 			public void run() {
 				try {
@@ -267,10 +192,10 @@ implements OnItemSelectedListener, OnClickListener {
 					// Don't use an earlier timestamp than the parent
 					long time = System.currentTimeMillis();
 					time = Math.max(time, timestamp + 1);
-					Message m = messageFactory.createPrivateMessage(parentId,
-							"text/plain", time, body);
+					Message m = messageFactory.createAnonymousMessage(parentId,
+							group, "text/plain", time, body);
 					long now = System.currentTimeMillis();
-					db.addLocalPrivateMessage(m, contactId);
+					db.addLocalMessage(m);
 					long duration = System.currentTimeMillis() - now;
 					if(LOG.isLoggable(INFO))
 						LOG.info("Storing message took " + duration + " ms");
diff --git a/briar-android/src/net/sf/briar/android/groups/ConfigureGroupActivity.java b/briar-android/src/net/sf/briar/android/groups/ConfigureGroupActivity.java
index 6241ba2f37..c96a1de59b 100644
--- a/briar-android/src/net/sf/briar/android/groups/ConfigureGroupActivity.java
+++ b/briar-android/src/net/sf/briar/android/groups/ConfigureGroupActivity.java
@@ -77,7 +77,7 @@ SelectContactsDialog.Listener {
 		setTitle(name);
 		b = i.getByteArrayExtra("net.sf.briar.GROUP_SALT");
 		if(b == null) throw new IllegalStateException();
-		group = new Group(id, name, b);
+		group = new Group(id, name, b, false);
 		subscribed = i.getBooleanExtra("net.sf.briar.SUBSCRIBED", false);
 		boolean all = i.getBooleanExtra("net.sf.briar.VISIBLE_TO_ALL", false);
 
@@ -207,11 +207,11 @@ SelectContactsDialog.Listener {
 					lifecycleManager.waitForDatabase();
 					long now = System.currentTimeMillis();
 					if(subscribe) {
-						if(!wasSubscribed) db.subscribe(group);
+						if(!wasSubscribed) db.addGroup(group);
 						db.setVisibleToAll(group.getId(), all);
 						if(!all) db.setVisibility(group.getId(), visible);
 					} else if(wasSubscribed) {
-						db.unsubscribe(group);
+						db.removeGroup(group);
 					}
 					long duration = System.currentTimeMillis() - now;
 					if(LOG.isLoggable(INFO))
diff --git a/briar-android/src/net/sf/briar/android/groups/CreateGroupActivity.java b/briar-android/src/net/sf/briar/android/groups/CreateGroupActivity.java
index dd7bb39812..08c3d948c2 100644
--- a/briar-android/src/net/sf/briar/android/groups/CreateGroupActivity.java
+++ b/briar-android/src/net/sf/briar/android/groups/CreateGroupActivity.java
@@ -13,7 +13,6 @@ import static java.util.logging.Level.WARNING;
 import static net.sf.briar.android.util.CommonLayoutParams.MATCH_MATCH;
 import static net.sf.briar.android.util.CommonLayoutParams.WRAP_WRAP;
 
-import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.concurrent.Executor;
@@ -174,9 +173,9 @@ SelectContactsDialog.Listener {
 				public void run() {
 					try {
 						lifecycleManager.waitForDatabase();
-						Group g = groupFactory.createGroup(name);
+						Group g = groupFactory.createGroup(name, false);
 						long now = System.currentTimeMillis();
-						db.subscribe(g);
+						db.addGroup(g);
 						if(all) db.setVisibleToAll(g.getId(), true);
 						else db.setVisibility(g.getId(), visible);
 						long duration = System.currentTimeMillis() - now;
@@ -189,8 +188,6 @@ SelectContactsDialog.Listener {
 						if(LOG.isLoggable(INFO))
 							LOG.info("Interrupted while waiting for database");
 						Thread.currentThread().interrupt();
-					} catch(IOException e) {
-						throw new RuntimeException(e);
 					}
 					runOnUiThread(new Runnable() {
 						public void run() {
diff --git a/briar-android/src/net/sf/briar/android/groups/GroupActivity.java b/briar-android/src/net/sf/briar/android/groups/GroupActivity.java
index 965137b0f5..fef29b665a 100644
--- a/briar-android/src/net/sf/briar/android/groups/GroupActivity.java
+++ b/briar-android/src/net/sf/briar/android/groups/GroupActivity.java
@@ -25,11 +25,11 @@ import net.sf.briar.api.Author;
 import net.sf.briar.api.android.DatabaseUiExecutor;
 import net.sf.briar.api.db.DatabaseComponent;
 import net.sf.briar.api.db.DbException;
-import net.sf.briar.api.db.GroupMessageHeader;
+import net.sf.briar.api.db.MessageHeader;
 import net.sf.briar.api.db.NoSuchSubscriptionException;
 import net.sf.briar.api.db.event.DatabaseEvent;
 import net.sf.briar.api.db.event.DatabaseListener;
-import net.sf.briar.api.db.event.GroupMessageAddedEvent;
+import net.sf.briar.api.db.event.MessageAddedEvent;
 import net.sf.briar.api.db.event.MessageExpiredEvent;
 import net.sf.briar.api.db.event.SubscriptionRemovedEvent;
 import net.sf.briar.api.lifecycle.LifecycleManager;
@@ -116,8 +116,8 @@ OnClickListener, OnItemClickListener {
 				try {
 					lifecycleManager.waitForDatabase();
 					long now = System.currentTimeMillis();
-					Collection<GroupMessageHeader> headers =
-							db.getGroupMessageHeaders(groupId);
+					Collection<MessageHeader> headers =
+							db.getMessageHeaders(groupId);
 					long duration = System.currentTimeMillis() - now;
 					if(LOG.isLoggable(INFO))
 						LOG.info("Load took " + duration + " ms");
@@ -141,13 +141,13 @@ OnClickListener, OnItemClickListener {
 		});
 	}
 
-	private void displayHeaders(final Collection<GroupMessageHeader> headers) {
+	private void displayHeaders(final Collection<MessageHeader> headers) {
 		runOnUiThread(new Runnable() {
 			public void run() {
 				list.setVisibility(VISIBLE);
 				loading.setVisibility(GONE);
 				adapter.clear();
-				for(GroupMessageHeader h : headers) adapter.add(h);
+				for(MessageHeader h : headers) adapter.add(h);
 				adapter.sort(AscendingHeaderComparator.INSTANCE);
 				adapter.notifyDataSetChanged();
 				selectFirstUnread();
@@ -187,9 +187,8 @@ OnClickListener, OnItemClickListener {
 	}
 
 	public void eventOccurred(DatabaseEvent e) {
-		if(e instanceof GroupMessageAddedEvent) {
-			GroupMessageAddedEvent g = (GroupMessageAddedEvent) e;
-			if(g.getGroup().getId().equals(groupId)) {
+		if(e instanceof MessageAddedEvent) {
+			if(((MessageAddedEvent) e).getGroup().getId().equals(groupId)) {
 				if(LOG.isLoggable(INFO)) LOG.info("Message added, reloading");
 				loadHeaders();
 			}
@@ -221,7 +220,7 @@ OnClickListener, OnItemClickListener {
 	}
 
 	private void displayMessage(int position) {
-		GroupMessageHeader item = adapter.getItem(position);
+		MessageHeader item = adapter.getItem(position);
 		Intent i = new Intent(this, ReadGroupPostActivity.class);
 		i.putExtra("net.sf.briar.GROUP_ID", groupId.getBytes());
 		i.putExtra("net.sf.briar.GROUP_NAME", groupName);
diff --git a/briar-android/src/net/sf/briar/android/groups/GroupAdapter.java b/briar-android/src/net/sf/briar/android/groups/GroupAdapter.java
index c85f452968..ac4241e9c6 100644
--- a/briar-android/src/net/sf/briar/android/groups/GroupAdapter.java
+++ b/briar-android/src/net/sf/briar/android/groups/GroupAdapter.java
@@ -8,7 +8,7 @@ import java.util.ArrayList;
 
 import net.sf.briar.R;
 import net.sf.briar.api.Author;
-import net.sf.briar.api.db.GroupMessageHeader;
+import net.sf.briar.api.db.MessageHeader;
 import android.content.Context;
 import android.content.res.Resources;
 import android.text.format.DateUtils;
@@ -18,16 +18,16 @@ import android.widget.ArrayAdapter;
 import android.widget.LinearLayout;
 import android.widget.TextView;
 
-class GroupAdapter extends ArrayAdapter<GroupMessageHeader> {
+class GroupAdapter extends ArrayAdapter<MessageHeader> {
 
 	GroupAdapter(Context ctx) {
 		super(ctx, android.R.layout.simple_expandable_list_item_1,
-				new ArrayList<GroupMessageHeader>());
+				new ArrayList<MessageHeader>());
 	}
 
 	@Override
 	public View getView(int position, View convertView, ViewGroup parent) {
-		GroupMessageHeader item = getItem(position);
+		MessageHeader item = getItem(position);
 		Context ctx = getContext();
 		Resources res = ctx.getResources();
 
diff --git a/briar-android/src/net/sf/briar/android/groups/GroupListActivity.java b/briar-android/src/net/sf/briar/android/groups/GroupListActivity.java
index 7b6d624e35..485685715d 100644
--- a/briar-android/src/net/sf/briar/android/groups/GroupListActivity.java
+++ b/briar-android/src/net/sf/briar/android/groups/GroupListActivity.java
@@ -27,11 +27,11 @@ import net.sf.briar.android.util.ListLoadingProgressBar;
 import net.sf.briar.api.android.DatabaseUiExecutor;
 import net.sf.briar.api.db.DatabaseComponent;
 import net.sf.briar.api.db.DbException;
-import net.sf.briar.api.db.GroupMessageHeader;
+import net.sf.briar.api.db.MessageHeader;
 import net.sf.briar.api.db.NoSuchSubscriptionException;
 import net.sf.briar.api.db.event.DatabaseEvent;
 import net.sf.briar.api.db.event.DatabaseListener;
-import net.sf.briar.api.db.event.GroupMessageAddedEvent;
+import net.sf.briar.api.db.event.MessageAddedEvent;
 import net.sf.briar.api.db.event.MessageExpiredEvent;
 import net.sf.briar.api.db.event.RemoteSubscriptionsUpdatedEvent;
 import net.sf.briar.api.db.event.SubscriptionAddedEvent;
@@ -140,10 +140,11 @@ OnItemClickListener {
 					long now = System.currentTimeMillis();
 					for(GroupStatus s : db.getAvailableGroups()) {
 						Group g = s.getGroup();
+						if(g.isPrivate()) continue;
 						if(s.isSubscribed()) {
 							try {
-								Collection<GroupMessageHeader> headers =
-										db.getGroupMessageHeaders(g.getId());
+								Collection<MessageHeader> headers =
+										db.getMessageHeaders(g.getId());
 								displayHeaders(g, headers);
 							} catch(NoSuchSubscriptionException e) {
 								if(LOG.isLoggable(INFO))
@@ -181,7 +182,7 @@ OnItemClickListener {
 	}
 
 	private void displayHeaders(final Group g,
-			final Collection<GroupMessageHeader> headers) {
+			final Collection<MessageHeader> headers) {
 		runOnUiThread(new Runnable() {
 			public void run() {
 				list.setVisibility(VISIBLE);
@@ -240,9 +241,12 @@ OnItemClickListener {
 	}
 
 	public void eventOccurred(DatabaseEvent e) {
-		if(e instanceof GroupMessageAddedEvent) {
-			if(LOG.isLoggable(INFO)) LOG.info("Message added, reloading");
-			loadHeaders(((GroupMessageAddedEvent) e).getGroup());
+		if(e instanceof MessageAddedEvent) {
+			Group g = ((MessageAddedEvent) e).getGroup();
+			if(!g.isPrivate()) {
+				if(LOG.isLoggable(INFO)) LOG.info("Message added, reloading");
+				loadHeaders(g);
+			}
 		} else if(e instanceof MessageExpiredEvent) {
 			if(LOG.isLoggable(INFO)) LOG.info("Message expired, reloading");
 			loadHeaders();
@@ -254,9 +258,12 @@ OnItemClickListener {
 			if(LOG.isLoggable(INFO)) LOG.info("Group added, reloading");
 			loadHeaders();
 		} else if(e instanceof SubscriptionRemovedEvent) {
-			// Reload the group, expecting NoSuchSubscriptionException
-			if(LOG.isLoggable(INFO)) LOG.info("Group removed, reloading");
-			loadHeaders(((SubscriptionRemovedEvent) e).getGroup());
+			Group g = ((SubscriptionRemovedEvent) e).getGroup();
+			if(!g.isPrivate()) {
+				// Reload the group, expecting NoSuchSubscriptionException
+				if(LOG.isLoggable(INFO)) LOG.info("Group removed, reloading");
+				loadHeaders(g);
+			}
 		}
 	}
 
@@ -266,8 +273,8 @@ OnItemClickListener {
 				try {
 					lifecycleManager.waitForDatabase();
 					long now = System.currentTimeMillis();
-					Collection<GroupMessageHeader> headers =
-							db.getGroupMessageHeaders(g.getId());
+					Collection<MessageHeader> headers =
+							db.getMessageHeaders(g.getId());
 					long duration = System.currentTimeMillis() - now;
 					if(LOG.isLoggable(INFO))
 						LOG.info("Partial load took " + duration + " ms");
@@ -307,8 +314,10 @@ OnItemClickListener {
 					lifecycleManager.waitForDatabase();
 					int available = 0;
 					long now = System.currentTimeMillis();
-					for(GroupStatus s : db.getAvailableGroups())
-						if(!s.isSubscribed()) available++;
+					for(GroupStatus s : db.getAvailableGroups()) {
+						if(!s.isSubscribed() && !s.getGroup().isPrivate())
+							available++;
+					}
 					long duration = System.currentTimeMillis() - now;
 					if(LOG.isLoggable(INFO))
 						LOG.info("Loading available took " + duration + " ms");
@@ -354,9 +363,9 @@ OnItemClickListener {
 			startActivity(new Intent(this, ManageGroupsActivity.class));
 		} else {
 			Intent i = new Intent(this, GroupActivity.class);
-			i.putExtra("net.sf.briar.GROUP_ID",
-					item.getGroup().getId().getBytes());
-			i.putExtra("net.sf.briar.GROUP_NAME", item.getGroup().getName());
+			Group g = item.getGroup();
+			i.putExtra("net.sf.briar.GROUP_ID", g.getId().getBytes());
+			i.putExtra("net.sf.briar.GROUP_NAME", g.getName());
 			startActivity(i);
 		}
 	}
diff --git a/briar-android/src/net/sf/briar/android/groups/GroupListItem.java b/briar-android/src/net/sf/briar/android/groups/GroupListItem.java
index cf8cd18c82..d82bb2feec 100644
--- a/briar-android/src/net/sf/briar/android/groups/GroupListItem.java
+++ b/briar-android/src/net/sf/briar/android/groups/GroupListItem.java
@@ -1,19 +1,16 @@
 package net.sf.briar.android.groups;
 
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.List;
 
-import net.sf.briar.android.DescendingHeaderComparator;
 import net.sf.briar.api.Author;
-import net.sf.briar.api.db.GroupMessageHeader;
+import net.sf.briar.api.db.MessageHeader;
 import net.sf.briar.api.messaging.Group;
 
 class GroupListItem {
 
 	static final GroupListItem MANAGE = new GroupListItem(null,
-			Collections.<GroupMessageHeader>emptyList());
+			Collections.<MessageHeader>emptyList());
 
 	private final Group group;
 	private final boolean empty;
@@ -21,7 +18,7 @@ class GroupListItem {
 	private final long timestamp;
 	private final int unread;
 
-	GroupListItem(Group group, Collection<GroupMessageHeader> headers) {
+	GroupListItem(Group group, Collection<MessageHeader> headers) {
 		this.group = group;
 		empty = headers.isEmpty();
 		if(empty) {
@@ -30,17 +27,21 @@ class GroupListItem {
 			timestamp = 0;
 			unread = 0;
 		} else {
-			List<GroupMessageHeader> list =
-					new ArrayList<GroupMessageHeader>(headers);
-			Collections.sort(list, DescendingHeaderComparator.INSTANCE);
-			GroupMessageHeader newest = list.get(0);
+			MessageHeader newest = null;
+			long timestamp = 0;
+			int unread = 0;
+			for(MessageHeader h : headers) {
+				if(h.getTimestamp() > timestamp) {
+					timestamp = h.getTimestamp();
+					newest = h;
+				}
+				if(!h.isRead()) unread++;
+			}
 			Author a = newest.getAuthor();
 			if(a == null) authorName = null;
 			else authorName = a.getName();
 			contentType = newest.getContentType();
-			timestamp = newest.getTimestamp();
-			int unread = 0;
-			for(GroupMessageHeader h : list) if(!h.isRead()) unread++;
+			this.timestamp = newest.getTimestamp();
 			this.unread = unread;
 		}
 	}
diff --git a/briar-android/src/net/sf/briar/android/groups/ManageGroupsActivity.java b/briar-android/src/net/sf/briar/android/groups/ManageGroupsActivity.java
index 14d326c9c0..a9be14f8fd 100644
--- a/briar-android/src/net/sf/briar/android/groups/ManageGroupsActivity.java
+++ b/briar-android/src/net/sf/briar/android/groups/ManageGroupsActivity.java
@@ -5,6 +5,7 @@ import static java.util.logging.Level.WARNING;
 import static net.sf.briar.android.groups.ManageGroupsItem.NONE;
 import static net.sf.briar.android.util.CommonLayoutParams.MATCH_MATCH;
 
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Comparator;
 import java.util.concurrent.Executor;
@@ -66,20 +67,23 @@ implements DatabaseListener, OnItemClickListener {
 	public void onResume() {
 		super.onResume();
 		db.addListener(this);
-		loadAvailableGroups();
+		loadGroups();
 	}
 
-	private void loadAvailableGroups() {
+	private void loadGroups() {
 		dbUiExecutor.execute(new Runnable() {
 			public void run() {
 				try {
 					lifecycleManager.waitForDatabase();
 					long now = System.currentTimeMillis();
-					Collection<GroupStatus> available = db.getAvailableGroups();
+					Collection<GroupStatus> available =
+							new ArrayList<GroupStatus>();
+					for(GroupStatus s : db.getAvailableGroups())
+						if(!s.getGroup().isPrivate()) available.add(s);
 					long duration = System.currentTimeMillis() - now;
 					if(LOG.isLoggable(INFO))
 						LOG.info("Load took " + duration + " ms");
-					displayAvailableGroups(available);
+					displayGroups(available);
 				} catch(DbException e) {
 					if(LOG.isLoggable(WARNING))
 						LOG.log(WARNING, e.toString(), e);
@@ -92,14 +96,13 @@ implements DatabaseListener, OnItemClickListener {
 		});
 	}
 
-	private void displayAvailableGroups(
-			final Collection<GroupStatus> available) {
+	private void displayGroups(final Collection<GroupStatus> available) {
 		runOnUiThread(new Runnable() {
 			public void run() {
 				setContentView(list);
 				adapter.clear();
-				for(GroupStatus g : available)
-					adapter.add(new ManageGroupsItem(g));
+				for(GroupStatus s : available)
+					adapter.add(new ManageGroupsItem(s));
 				adapter.sort(ItemComparator.INSTANCE);
 				adapter.notifyDataSetChanged();
 			}
@@ -116,13 +119,13 @@ implements DatabaseListener, OnItemClickListener {
 		if(e instanceof RemoteSubscriptionsUpdatedEvent) {
 			if(LOG.isLoggable(INFO))
 				LOG.info("Remote subscriptions changed, reloading");
-			loadAvailableGroups();
+			loadGroups();
 		} else if(e instanceof SubscriptionAddedEvent) {
 			if(LOG.isLoggable(INFO)) LOG.info("Group added, reloading");
-			loadAvailableGroups();
+			loadGroups();
 		} else if(e instanceof SubscriptionRemovedEvent) {
 			if(LOG.isLoggable(INFO)) LOG.info("Group removed, reloading");
-			loadAvailableGroups();
+			loadGroups();
 		}
 	}
 
diff --git a/briar-android/src/net/sf/briar/android/groups/WriteGroupPostActivity.java b/briar-android/src/net/sf/briar/android/groups/WriteGroupPostActivity.java
index 4ea20fef52..fc79780222 100644
--- a/briar-android/src/net/sf/briar/android/groups/WriteGroupPostActivity.java
+++ b/briar-android/src/net/sf/briar/android/groups/WriteGroupPostActivity.java
@@ -11,6 +11,7 @@ import static net.sf.briar.android.util.CommonLayoutParams.MATCH_WRAP;
 
 import java.io.IOException;
 import java.security.GeneralSecurityException;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.concurrent.Executor;
 import java.util.logging.Logger;
@@ -71,10 +72,10 @@ implements OnItemSelectedListener, OnClickListener {
 	@Inject private volatile DatabaseComponent db;
 	@Inject @DatabaseUiExecutor private volatile Executor dbUiExecutor;
 	@Inject private volatile LifecycleManager lifecycleManager;
-	private volatile LocalAuthor localAuthor = null;
-	private volatile Group group = null;
 	private volatile MessageId parentId = null;
 	private volatile long timestamp = -1;
+	private volatile LocalAuthor localAuthor = null;
+	private volatile Group group = null;
 
 	@Override
 	public void onCreate(Bundle state) {
@@ -213,7 +214,9 @@ implements OnItemSelectedListener, OnClickListener {
 				try {
 					lifecycleManager.waitForDatabase();
 					long now = System.currentTimeMillis();
-					Collection<Group> groups = db.getSubscriptions();
+					Collection<Group> groups = new ArrayList<Group>();
+					for(Group g : db.getGroups())
+						if(!g.isPrivate()) groups.add(g);
 					long duration = System.currentTimeMillis() - now;
 					if(LOG.isLoggable(INFO))
 						LOG.info("Loading groups took " + duration + " ms");
@@ -341,7 +344,7 @@ implements OnItemSelectedListener, OnClickListener {
 				try {
 					lifecycleManager.waitForDatabase();
 					long now = System.currentTimeMillis();
-					db.addLocalGroupMessage(m);
+					db.addLocalMessage(m);
 					long duration = System.currentTimeMillis() - now;
 					if(LOG.isLoggable(INFO))
 						LOG.info("Storing message took " + duration + " ms");
diff --git a/briar-android/src/net/sf/briar/android/identity/CreateIdentityActivity.java b/briar-android/src/net/sf/briar/android/identity/CreateIdentityActivity.java
index bd4feb7c04..2010c551ef 100644
--- a/briar-android/src/net/sf/briar/android/identity/CreateIdentityActivity.java
+++ b/briar-android/src/net/sf/briar/android/identity/CreateIdentityActivity.java
@@ -13,7 +13,6 @@ import static java.util.logging.Level.WARNING;
 import static net.sf.briar.android.util.CommonLayoutParams.MATCH_MATCH;
 import static net.sf.briar.android.util.CommonLayoutParams.WRAP_WRAP;
 
-import java.io.IOException;
 import java.util.concurrent.Executor;
 import java.util.logging.Logger;
 
@@ -123,13 +122,8 @@ implements OnEditorActionListener, OnClickListener {
 				KeyPair keyPair = crypto.generateSignatureKeyPair();
 				final byte[] publicKey = keyPair.getPublic().getEncoded();
 				final byte[] privateKey = keyPair.getPrivate().getEncoded();
-				LocalAuthor a;
-				try {
-					a = authorFactory.createLocalAuthor(nickname, publicKey,
-							privateKey);
-				} catch(IOException e) {
-					throw new RuntimeException(e);
-				}
+				LocalAuthor a = authorFactory.createLocalAuthor(nickname,
+						publicKey, privateKey);
 				storeLocalAuthor(a);
 			}
 		});
diff --git a/briar-api/src/net/sf/briar/api/AuthorFactory.java b/briar-api/src/net/sf/briar/api/AuthorFactory.java
index 5b881f9f5b..dfb808a15e 100644
--- a/briar-api/src/net/sf/briar/api/AuthorFactory.java
+++ b/briar-api/src/net/sf/briar/api/AuthorFactory.java
@@ -1,11 +1,9 @@
 package net.sf.briar.api;
 
-import java.io.IOException;
-
 public interface AuthorFactory {
 
-	Author createAuthor(String name, byte[] publicKey) throws IOException;
+	Author createAuthor(String name, byte[] publicKey);
 
 	LocalAuthor createLocalAuthor(String name, byte[] publicKey,
-			byte[] privateKey) throws IOException;
+			byte[] privateKey);
 }
diff --git a/briar-api/src/net/sf/briar/api/crypto/CryptoComponent.java b/briar-api/src/net/sf/briar/api/crypto/CryptoComponent.java
index baee643e92..a3bf48e15c 100644
--- a/briar-api/src/net/sf/briar/api/crypto/CryptoComponent.java
+++ b/briar-api/src/net/sf/briar/api/crypto/CryptoComponent.java
@@ -47,6 +47,9 @@ public interface CryptoComponent {
 	byte[] deriveMasterSecret(byte[] theirPublicKey, KeyPair ourKeyPair,
 			boolean alice) throws GeneralSecurityException;
 
+	/** Derives a group salt from the given master secret. */
+	byte[] deriveGroupSalt(byte[] secret);
+
 	/**
 	 * Derives an initial secret for the given transport from the given master
 	 * secret.
diff --git a/briar-api/src/net/sf/briar/api/db/DatabaseComponent.java b/briar-api/src/net/sf/briar/api/db/DatabaseComponent.java
index 4e438d2781..53dbba1cde 100644
--- a/briar-api/src/net/sf/briar/api/db/DatabaseComponent.java
+++ b/briar-api/src/net/sf/briar/api/db/DatabaseComponent.java
@@ -48,22 +48,25 @@ public interface DatabaseComponent {
 	void removeListener(DatabaseListener d);
 
 	/**
-	 * Stores a contact with the given pseudonym, associated with the given
-	 * local pseudonym, and returns an ID for the contact.
+	 * Stores a contact associated with the given local and remote pseudonyms,
+	 * and returns an ID for the contact.
 	 */
 	ContactId addContact(Author remote, AuthorId local) throws DbException;
 
 	/** Stores an endpoint. */
 	void addEndpoint(Endpoint ep) throws DbException;
 
+	/**
+	 * Subscribes to a group, or returns false if the user already has the
+	 * maximum number of public subscriptions.
+	 */
+	boolean addGroup(Group g) throws DbException;
+
 	/** Stores a local pseudonym. */
 	void addLocalAuthor(LocalAuthor a) throws DbException;
 
-	/** Stores a locally generated group message. */
-	void addLocalGroupMessage(Message m) throws DbException;
-
-	/** Stores a locally generated private message. */
-	void addLocalPrivateMessage(Message m, ContactId c) throws DbException;
+	/** Stores a local message. */
+	void addLocalMessage(Message m) throws DbException;
 
 	/**
 	 * Stores the given temporary secrets and deletes any secrets that have
@@ -77,14 +80,17 @@ public interface DatabaseComponent {
 	 */
 	boolean addTransport(TransportId t, long maxLatency) throws DbException;
 
+	/** Returns true if any messages are sendable to the given contact. */
+	boolean containsSendableMessages(ContactId c) throws DbException;
+
 	/**
-	 * Generates an acknowledgement for the given contact, or returns null if
-	 * there are no messages to acknowledge.
+	 * Returns an acknowledgement for the given contact, or null if there are
+	 * no messages to acknowledge.
 	 */
 	Ack generateAck(ContactId c, int maxMessages) throws DbException;
 
 	/**
-	 * Generates a batch of raw messages for the given contact, with a total
+	 * Returns a batch of raw messages for the given contact, with a total
 	 * length less than or equal to the given length, for transmission over a
 	 * transport with the given maximum latency. Returns null if there are no
 	 * sendable messages that fit in the given length.
@@ -93,7 +99,7 @@ public interface DatabaseComponent {
 			long maxLatency) throws DbException;
 
 	/**
-	 * Generates a batch of raw messages for the given contact from the given
+	 * Returns a batch of raw messages for the given contact from the given
 	 * collection of requested messages, with a total length less than or equal
 	 * to the given length, for transmission over a transport with the given
 	 * maximum latency. Any messages that were either added to the batch, or
@@ -106,19 +112,19 @@ public interface DatabaseComponent {
 					throws DbException;
 
 	/**
-	 * Generates an offer for the given contact, or returns null if there are
-	 * no messages to offer.
+	 * Returns an offer for the given contact, or null if there are no messages
+	 * to offer.
 	 */
 	Offer generateOffer(ContactId c, int maxMessages) throws DbException;
 
 	/**
-	 * Generates a retention ack for the given contact, or returns null if no
+	 * Returns a retention ack for the given contact, or null if no retention
 	 * ack is due.
 	 */
 	RetentionAck generateRetentionAck(ContactId c) throws DbException;
 
 	/**
-	 * Generates a retention update for the given contact, for transmission
+	 * Returns a retention update for the given contact, for transmission
 	 * over a transport with the given latency. Returns null if no update is
 	 * due.
 	 */
@@ -126,13 +132,13 @@ public interface DatabaseComponent {
 			throws DbException;
 
 	/**
-	 * Generates a subscription ack for the given contact, or returns null if
-	 * no ack is due.
+	 * Returns a subscription ack for the given contact, or null if no
+	 * subscription ack is due.
 	 */
 	SubscriptionAck generateSubscriptionAck(ContactId c) throws DbException;
 
 	/**
-	 * Generates a subscription update for the given contact, for transmission
+	 * Returns a subscription update for the given contact, for transmission
 	 * over a transport with the given latency. Returns null if no update is
 	 * due.
 	 */
@@ -140,14 +146,14 @@ public interface DatabaseComponent {
 			throws DbException;
 
 	/**
-	 * Generates a batch of transport acks for the given contact, or returns
-	 * null if no acks are due.
+	 * Returns a batch of transport acks for the given contact, or null if no
+	 * transport acks are due.
 	 */
 	Collection<TransportAck> generateTransportAcks(ContactId c)
 			throws DbException;
 
 	/**
-	 * Generates a batch of transport updates for the given contact, for
+	 * Returns a batch of transport updates for the given contact, for
 	 * transmission over a transport with the given latency. Returns null if no
 	 * updates are due.
 	 */
@@ -169,8 +175,20 @@ public interface DatabaseComponent {
 	/** Returns the group with the given ID, if the user subscribes to it. */
 	Group getGroup(GroupId g) throws DbException;
 
-	/** Returns the headers of all messages in the given group. */
-	Collection<GroupMessageHeader> getGroupMessageHeaders(GroupId g)
+	/** Returns all groups to which the user subscribes. */
+	Collection<Group> getGroups() throws DbException;
+
+	/**
+	 * Returns the ID of the inbox group for the given contact, or null if no
+	 * inbox group has been set.
+	 */
+	GroupId getInboxGroup(ContactId c) throws DbException;
+
+	/**
+	 * Returns the headers of all messages in the inbox group for the given
+	 * contact, or null if no inbox group has been set.
+	 */
+	Collection<MessageHeader> getInboxMessageHeaders(ContactId c)
 			throws DbException;
 
 	/**
@@ -195,11 +213,8 @@ public interface DatabaseComponent {
 	/** Returns the body of the message with the given ID. */
 	byte[] getMessageBody(MessageId m) throws DbException;
 
-	/**
-	 * Returns the headers of all private messages to or from the given
-	 * contact.
-	 */
-	Collection<PrivateMessageHeader> getPrivateMessageHeaders(ContactId c)
+	/** Returns the headers of all messages in the given group. */
+	Collection<MessageHeader> getMessageHeaders(GroupId g)
 			throws DbException;
 
 	/** Returns true if the given message has been read. */
@@ -212,24 +227,15 @@ public interface DatabaseComponent {
 	/** Returns all temporary secrets. */
 	Collection<TemporarySecret> getSecrets() throws DbException;
 
-	/** Returns the set of groups to which the user subscribes. */
-	Collection<Group> getSubscriptions() throws DbException;
-
 	/** Returns the maximum latencies of all local transports. */
 	Map<TransportId, Long> getTransportLatencies() throws DbException;
 
 	/** Returns the number of unread messages in each subscribed group. */
 	Map<GroupId, Integer> getUnreadMessageCounts() throws DbException;
 
-	/** Returns the contacts to which the given group is visible. */
+	/** Returns the IDs of all contacts to which the given group is visible. */
 	Collection<ContactId> getVisibility(GroupId g) throws DbException;
 
-	/** Returns the subscriptions that are visible to the given contact. */
-	Collection<GroupId> getVisibleSubscriptions(ContactId c) throws DbException;
-
-	/** Returns true if any messages are sendable to the given contact. */
-	boolean hasSendableMessages(ContactId c) throws DbException;
-
 	/**
 	 * Increments the outgoing connection counter for the given endpoint
 	 * in the given rotation period and returns the old value, or -1 if the
@@ -295,6 +301,12 @@ public interface DatabaseComponent {
 	/** Removes a contact (and all associated state) from the database. */
 	void removeContact(ContactId c) throws DbException;
 
+	/**
+	 * Unsubscribes from a group. Any messages belonging to the group
+	 * are deleted from the database.
+	 */
+	void removeGroup(Group g) throws DbException;
+
 	/**
 	 * Removes a local pseudonym (and all associated state) from the database.
 	 */
@@ -314,8 +326,14 @@ public interface DatabaseComponent {
 			long centre, byte[] bitmap) throws DbException;
 
 	/**
-	 * Marks the given message read or unread and returns true if it was
-	 * previously read.
+	 * Makes a private group visible to the given contact, adds it to the
+	 * contact's subscriptions, and sets it as the inbox group for the contact.
+	 */
+	public void setInboxGroup(ContactId c, Group g) throws DbException;
+
+	/**
+	 * Marks a message read or unread and returns true if it was previously
+	 * read.
 	 */
 	boolean setReadFlag(MessageId m, boolean read) throws DbException;
 
@@ -342,16 +360,4 @@ public interface DatabaseComponent {
 	 * current contacts.
 	 */
 	void setVisibleToAll(GroupId g, boolean all) throws DbException;
-
-	/**
-	 * Subscribes to the given group, or returns false if the user already has
-	 * the maximum number of subscriptions.
-	 */
-	boolean subscribe(Group g) throws DbException;
-
-	/**
-	 * Unsubscribes from the given group. Any messages belonging to the group
-	 * are deleted from the database.
-	 */
-	void unsubscribe(Group g) throws DbException;
 }
diff --git a/briar-api/src/net/sf/briar/api/db/GroupMessageHeader.java b/briar-api/src/net/sf/briar/api/db/GroupMessageHeader.java
deleted file mode 100644
index e7f81f8b62..0000000000
--- a/briar-api/src/net/sf/briar/api/db/GroupMessageHeader.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package net.sf.briar.api.db;
-
-import net.sf.briar.api.Author;
-import net.sf.briar.api.messaging.GroupId;
-import net.sf.briar.api.messaging.MessageId;
-
-public class GroupMessageHeader extends MessageHeader {
-
-	private final GroupId groupId;
-
-	public GroupMessageHeader(MessageId id, MessageId parent, Author author,
-			String contentType, long timestamp, boolean read,
-			GroupId groupId) {
-		super(id, parent, author, contentType, timestamp, read);
-		this.groupId = groupId;
-	}
-
-	/** Returns the ID of the group to which the message belongs. */
-	public GroupId getGroupId() {
-		return groupId;
-	}
-}
diff --git a/briar-api/src/net/sf/briar/api/db/MessageHeader.java b/briar-api/src/net/sf/briar/api/db/MessageHeader.java
index 8c1dc20eb4..90822f583b 100644
--- a/briar-api/src/net/sf/briar/api/db/MessageHeader.java
+++ b/briar-api/src/net/sf/briar/api/db/MessageHeader.java
@@ -1,20 +1,23 @@
 package net.sf.briar.api.db;
 
 import net.sf.briar.api.Author;
+import net.sf.briar.api.messaging.GroupId;
 import net.sf.briar.api.messaging.MessageId;
 
-public abstract class MessageHeader {
+public class MessageHeader {
 
 	private final MessageId id, parent;
+	private final GroupId groupId;
 	private final Author author;
 	private final String contentType;
 	private final long timestamp;
 	private final boolean read;
 
-	protected MessageHeader(MessageId id, MessageId parent, Author author,
-			String contentType, long timestamp, boolean read) {
+	public MessageHeader(MessageId id, MessageId parent, GroupId groupId,
+			Author author, String contentType, long timestamp, boolean read) {
 		this.id = id;
 		this.parent = parent;
+		this.groupId = groupId;
 		this.author = author;
 		this.contentType = contentType;
 		this.timestamp = timestamp;
@@ -34,6 +37,13 @@ public abstract class MessageHeader {
 		return parent;
 	}
 
+	/**
+	 * Returns the unique identifier of the group to which the message belongs.
+	 */
+	public GroupId getGroupId() {
+		return groupId;
+	}
+
 	/**
 	 * Returns the message's author, or null if this is an  anonymous message.
 	 */
diff --git a/briar-api/src/net/sf/briar/api/db/PrivateMessageHeader.java b/briar-api/src/net/sf/briar/api/db/PrivateMessageHeader.java
deleted file mode 100644
index 06abefef12..0000000000
--- a/briar-api/src/net/sf/briar/api/db/PrivateMessageHeader.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package net.sf.briar.api.db;
-
-import net.sf.briar.api.Author;
-import net.sf.briar.api.ContactId;
-import net.sf.briar.api.messaging.MessageId;
-
-public class PrivateMessageHeader extends MessageHeader {
-
-	private final ContactId contactId;
-	private final boolean incoming;
-
-	public PrivateMessageHeader(MessageId id, MessageId parent, Author author,
-			String contentType, long timestamp, boolean read,
-			ContactId contactId, boolean incoming) {
-		super(id, parent, author, contentType, timestamp, read);
-		this.contactId = contactId;
-		this.incoming = incoming;
-	}
-
-	/**
-	 * Returns the ID of the contact who is the sender (if incoming) or
-	 * recipient (if outgoing) of this message.
-	 */
-	public ContactId getContactId() {
-		return contactId;
-	}
-
-	/** Returns true if this is an incoming message. */
-	public boolean isIncoming() {
-		return incoming;
-	}
-}
diff --git a/briar-api/src/net/sf/briar/api/db/event/GroupMessageAddedEvent.java b/briar-api/src/net/sf/briar/api/db/event/GroupMessageAddedEvent.java
deleted file mode 100644
index 40c5510301..0000000000
--- a/briar-api/src/net/sf/briar/api/db/event/GroupMessageAddedEvent.java
+++ /dev/null
@@ -1,23 +0,0 @@
-package net.sf.briar.api.db.event;
-
-import net.sf.briar.api.messaging.Group;
-
-/** An event that is broadcast when a group message is added to the database. */
-public class GroupMessageAddedEvent extends DatabaseEvent {
-
-	private final Group group;
-	private final boolean incoming;
-
-	public GroupMessageAddedEvent(Group group, boolean incoming) {
-		this.group = group;
-		this.incoming = incoming;
-	}
-
-	public Group getGroup() {
-		return group;
-	}
-
-	public boolean isIncoming() {
-		return incoming;
-	}
-}
diff --git a/briar-api/src/net/sf/briar/api/db/event/LocalAuthorAddedEvent.java b/briar-api/src/net/sf/briar/api/db/event/LocalAuthorAddedEvent.java
index 4b72402fa3..1d9c305437 100644
--- a/briar-api/src/net/sf/briar/api/db/event/LocalAuthorAddedEvent.java
+++ b/briar-api/src/net/sf/briar/api/db/event/LocalAuthorAddedEvent.java
@@ -2,7 +2,7 @@ package net.sf.briar.api.db.event;
 
 import net.sf.briar.api.AuthorId;
 
-/** An event that is broadcast when a pseudonym for the user is added. */
+/** An event that is broadcast when a local pseudonym is added. */
 public class LocalAuthorAddedEvent extends DatabaseEvent {
 
 	private final AuthorId authorId;
diff --git a/briar-api/src/net/sf/briar/api/db/event/LocalAuthorRemovedEvent.java b/briar-api/src/net/sf/briar/api/db/event/LocalAuthorRemovedEvent.java
index 0b5bd8849e..cf4e18e758 100644
--- a/briar-api/src/net/sf/briar/api/db/event/LocalAuthorRemovedEvent.java
+++ b/briar-api/src/net/sf/briar/api/db/event/LocalAuthorRemovedEvent.java
@@ -2,7 +2,7 @@ package net.sf.briar.api.db.event;
 
 import net.sf.briar.api.AuthorId;
 
-/** An event that is broadcast when a pseudonym for the user is removed. */
+/** An event that is broadcast when a local pseudonym is removed. */
 public class LocalAuthorRemovedEvent extends DatabaseEvent {
 
 	private final AuthorId authorId;
diff --git a/briar-api/src/net/sf/briar/api/db/event/MessageAddedEvent.java b/briar-api/src/net/sf/briar/api/db/event/MessageAddedEvent.java
new file mode 100644
index 0000000000..8f04e5d141
--- /dev/null
+++ b/briar-api/src/net/sf/briar/api/db/event/MessageAddedEvent.java
@@ -0,0 +1,29 @@
+package net.sf.briar.api.db.event;
+
+import net.sf.briar.api.ContactId;
+import net.sf.briar.api.messaging.Group;
+
+/** An event that is broadcast when a message is added to the database. */
+public class MessageAddedEvent extends DatabaseEvent {
+
+	private final Group group;
+	private final ContactId contactId;
+
+	public MessageAddedEvent(Group group, ContactId contactId) {
+		this.group = group;
+		this.contactId = contactId;
+	}
+
+	/** Returns the group to which the message belongs. */
+	public Group getGroup() {
+		return group;
+	}
+
+	/**
+	 * Returns the ID of the contact from which the message was received, or
+	 * null if the message was locally generated.
+	 */
+	public ContactId getContactId() {
+		return contactId;
+	}
+}
diff --git a/briar-api/src/net/sf/briar/api/db/event/PrivateMessageAddedEvent.java b/briar-api/src/net/sf/briar/api/db/event/PrivateMessageAddedEvent.java
deleted file mode 100644
index 1ce1dafd37..0000000000
--- a/briar-api/src/net/sf/briar/api/db/event/PrivateMessageAddedEvent.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package net.sf.briar.api.db.event;
-
-import net.sf.briar.api.ContactId;
-
-/**
- * An event that is broadcast when a private message is added to the database.
- */
-public class PrivateMessageAddedEvent extends DatabaseEvent {
-
-	private final ContactId contactId;
-	private final boolean incoming;
-
-	public PrivateMessageAddedEvent(ContactId contactId, boolean incoming) {
-		this.contactId = contactId;
-		this.incoming = incoming;
-	}
-
-	public ContactId getContactId() {
-		return contactId;
-	}
-
-	public boolean isIncoming() {
-		return incoming;
-	}
-}
diff --git a/briar-api/src/net/sf/briar/api/messaging/Group.java b/briar-api/src/net/sf/briar/api/messaging/Group.java
index a778b24dff..37b1d376c1 100644
--- a/briar-api/src/net/sf/briar/api/messaging/Group.java
+++ b/briar-api/src/net/sf/briar/api/messaging/Group.java
@@ -6,11 +6,13 @@ public class Group {
 	private final GroupId id;
 	private final String name;
 	private final byte[] salt;
+	private final boolean isPrivate;
 
-	public Group(GroupId id, String name, byte[] salt) {
+	public Group(GroupId id, String name, byte[] salt, boolean isPrivate) {
 		this.id = id;
 		this.name = name;
 		this.salt = salt;
+		this.isPrivate = isPrivate;
 	}
 
 	/** Returns the group's unique identifier. */
@@ -31,6 +33,11 @@ public class Group {
 		return salt;
 	}
 
+	/** Returns true if the group is private. */
+	public boolean isPrivate() {
+		return isPrivate;
+	}
+
 	@Override
 	public int hashCode() {
 		return id.hashCode();
diff --git a/briar-api/src/net/sf/briar/api/messaging/GroupFactory.java b/briar-api/src/net/sf/briar/api/messaging/GroupFactory.java
index 1535cab6cf..ea3d905342 100644
--- a/briar-api/src/net/sf/briar/api/messaging/GroupFactory.java
+++ b/briar-api/src/net/sf/briar/api/messaging/GroupFactory.java
@@ -1,12 +1,10 @@
 package net.sf.briar.api.messaging;
 
-import java.io.IOException;
-
 public interface GroupFactory {
 
 	/** Creates a group with the given name and a random salt. */
-	Group createGroup(String name) throws IOException;
+	Group createGroup(String name, boolean isPrivate);
 
 	/** Creates a group with the given name and salt. */
-	Group createGroup(String name, byte[] salt) throws IOException;
+	Group createGroup(String name, byte[] salt, boolean isPrivate);
 }
diff --git a/briar-api/src/net/sf/briar/api/messaging/MessageFactory.java b/briar-api/src/net/sf/briar/api/messaging/MessageFactory.java
index 6e4dc2dd25..0c798ebb71 100644
--- a/briar-api/src/net/sf/briar/api/messaging/MessageFactory.java
+++ b/briar-api/src/net/sf/briar/api/messaging/MessageFactory.java
@@ -8,17 +8,10 @@ import net.sf.briar.api.crypto.PrivateKey;
 
 public interface MessageFactory {
 
-	/** Creates a private message. */
-	Message createPrivateMessage(MessageId parent, String contentType,
-			long timestamp, byte[] body) throws IOException,
-			GeneralSecurityException;
-
-	/** Creates an anonymous group message. */
 	Message createAnonymousMessage(MessageId parent, Group group,
 			String contentType, long timestamp, byte[] body) throws IOException,
 			GeneralSecurityException;
 
-	/** Creates a pseudonymous group message. */
 	Message createPseudonymousMessage(MessageId parent, Group group,
 			Author author, PrivateKey privateKey, String contentType,
 			long timestamp, byte[] body) throws IOException,
diff --git a/briar-api/src/net/sf/briar/api/messaging/MessagingConstants.java b/briar-api/src/net/sf/briar/api/messaging/MessagingConstants.java
index 80a1d7ddfe..3b1347f91c 100644
--- a/briar-api/src/net/sf/briar/api/messaging/MessagingConstants.java
+++ b/briar-api/src/net/sf/briar/api/messaging/MessagingConstants.java
@@ -11,7 +11,7 @@ public interface MessagingConstants {
 	 */
 	int MAX_PACKET_LENGTH = MIN_CONNECTION_LENGTH / 2;
 
-	/** The maximum number of groups a user may subscribe to. */
+	/** The maximum number of public groups a user may subscribe to. */
 	int MAX_SUBSCRIPTIONS = 3000;
 
 	/** The maximum length of a group's name in UTF-8 bytes. */
diff --git a/briar-api/src/net/sf/briar/api/messaging/SubscriptionUpdate.java b/briar-api/src/net/sf/briar/api/messaging/SubscriptionUpdate.java
index 337d23b991..48c3cda3c9 100644
--- a/briar-api/src/net/sf/briar/api/messaging/SubscriptionUpdate.java
+++ b/briar-api/src/net/sf/briar/api/messaging/SubscriptionUpdate.java
@@ -5,11 +5,11 @@ import java.util.Collection;
 /** A packet updating the recipient's view of the sender's subscriptions. */
 public class SubscriptionUpdate {
 
-	private final Collection<Group> subs;
+	private final Collection<Group> groups;
 	private final long version;
 
-	public SubscriptionUpdate(Collection<Group> subs, long version) {
-		this.subs = subs;
+	public SubscriptionUpdate(Collection<Group> groups, long version) {
+		this.groups = groups;
 		this.version = version;
 	}
 
@@ -18,7 +18,7 @@ public class SubscriptionUpdate {
 	 * has made visible to the recipient.
 	 */
 	public Collection<Group> getGroups() {
-		return subs;
+		return groups;
 	}
 
 	/** Returns the update's version number. */
diff --git a/briar-api/src/net/sf/briar/api/messaging/UnverifiedMessage.java b/briar-api/src/net/sf/briar/api/messaging/UnverifiedMessage.java
index bbea6bdd4d..880d3b3d1a 100644
--- a/briar-api/src/net/sf/briar/api/messaging/UnverifiedMessage.java
+++ b/briar-api/src/net/sf/briar/api/messaging/UnverifiedMessage.java
@@ -8,19 +8,18 @@ public class UnverifiedMessage {
 	private final MessageId parent;
 	private final Group group;
 	private final Author author;
-	private final String contentType, subject;
+	private final String contentType;
 	private final long timestamp;
 	private final byte[] raw, signature;
 	private final int bodyStart, bodyLength, signedLength;
 
 	public UnverifiedMessage(MessageId parent, Group group, Author author,
-			String contentType, String subject, long timestamp, byte[] raw,
-			byte[] signature, int bodyStart, int bodyLength, int signedLength) {
+			String contentType, long timestamp, byte[] raw, byte[] signature,
+			int bodyStart, int bodyLength, int signedLength) {
 		this.parent = parent;
 		this.group = group;
 		this.author = author;
 		this.contentType = contentType;
-		this.subject = subject;
 		this.timestamp = timestamp;
 		this.raw = raw;
 		this.signature = signature;
@@ -58,15 +57,6 @@ public class UnverifiedMessage {
 		return contentType;
 	}
 
-	/**
-	 * Returns the message's subject line, which is created from the first 50
-	 * bytes of the message body if the content type is text/plain, or is the
-	 * empty string otherwise.
-	 */
-	public String getSubject() {
-		return subject;
-	}
-
 	/** Returns the message's timestamp. */
 	public long getTimestamp() {
 		return timestamp;
diff --git a/briar-core/src/net/sf/briar/crypto/CryptoComponentImpl.java b/briar-core/src/net/sf/briar/crypto/CryptoComponentImpl.java
index 682fc7071d..566aec70b2 100644
--- a/briar-core/src/net/sf/briar/crypto/CryptoComponentImpl.java
+++ b/briar-core/src/net/sf/briar/crypto/CryptoComponentImpl.java
@@ -64,6 +64,7 @@ class CryptoComponentImpl implements CryptoComponent {
 
 	// Labels for secret derivation
 	private static final byte[] MASTER = { 'M', 'A', 'S', 'T', 'E', 'R', '\0' };
+	private static final byte[] SALT = { 'S', 'A', 'L', 'T', '\0' };
 	private static final byte[] FIRST = { 'F', 'I', 'R', 'S', 'T', '\0' };
 	private static final byte[] ROTATE = { 'R', 'O', 'T', 'A', 'T', 'E', '\0' };
 	// Label for confirmation code derivation
@@ -235,6 +236,14 @@ class CryptoComponentImpl implements CryptoComponent {
 		return agreement.calculateAgreement(ecPub).toByteArray();
 	}
 
+	public byte[] deriveGroupSalt(byte[] secret) {
+		if(secret.length != CIPHER_KEY_BYTES)
+			throw new IllegalArgumentException();
+		if(Arrays.equals(secret, BLANK_SECRET))
+			throw new IllegalArgumentException();
+		return counterModeKdf(secret, SALT, 0);
+	}
+
 	public byte[] deriveInitialSecret(byte[] secret, int transportIndex) {
 		if(secret.length != CIPHER_KEY_BYTES)
 			throw new IllegalArgumentException();
diff --git a/briar-core/src/net/sf/briar/db/Database.java b/briar-core/src/net/sf/briar/db/Database.java
index 1a10cc82db..82b20351b8 100644
--- a/briar-core/src/net/sf/briar/db/Database.java
+++ b/briar-core/src/net/sf/briar/db/Database.java
@@ -13,8 +13,7 @@ import net.sf.briar.api.TransportConfig;
 import net.sf.briar.api.TransportId;
 import net.sf.briar.api.TransportProperties;
 import net.sf.briar.api.db.DbException;
-import net.sf.briar.api.db.GroupMessageHeader;
-import net.sf.briar.api.db.PrivateMessageHeader;
+import net.sf.briar.api.db.MessageHeader;
 import net.sf.briar.api.messaging.Group;
 import net.sf.briar.api.messaging.GroupId;
 import net.sf.briar.api.messaging.GroupStatus;
@@ -79,8 +78,8 @@ interface Database<T> {
 	void commitTransaction(T txn) throws DbException;
 
 	/**
-	 * Stores a contact with the given pseudonym, associated with the given
-	 * local pseudonym, and returns an ID for the contact.
+	 * Stores a contact associated with the given local and remote pseudonyms,
+	 * and returns an ID for the contact.
 	 * <p>
 	 * Locking: contact write, message write, retention write,
 	 * subscription write, transport write, window write.
@@ -96,13 +95,12 @@ interface Database<T> {
 	void addEndpoint(T txn, Endpoint ep) throws DbException;
 
 	/**
-	 * Stores the given message, or returns false if the message is already in
-	 * the database.
+	 * Subscribes to a group, or returns false if the user already has the
+	 * maximum number of subscriptions.
 	 * <p>
-	 * Locking: message write.
+	 * Locking: message write, subscription write.
 	 */
-	boolean addGroupMessage(T txn, Message m, boolean incoming)
-			throws DbException;
+	boolean addGroup(T txn, Group g) throws DbException;
 
 	/**
 	 * Stores a local pseudonym.
@@ -113,20 +111,18 @@ interface Database<T> {
 	void addLocalAuthor(T txn, LocalAuthor a) throws DbException;
 
 	/**
-	 * Records a received message as needing to be acknowledged.
+	 * Stores a message.
 	 * <p>
 	 * Locking: message write.
 	 */
-	void addMessageToAck(T txn, ContactId c, MessageId m) throws DbException;
+	void addMessage(T txn, Message m, boolean incoming) throws DbException;
 
 	/**
-	 * Stores the given message, or returns false if the message is already in
-	 * the database.
+	 * Records a received message as needing to be acknowledged.
 	 * <p>
 	 * Locking: message write.
 	 */
-	boolean addPrivateMessage(T txn, Message m, ContactId c, boolean incoming)
-			throws DbException;
+	void addMessageToAck(T txn, ContactId c, MessageId m) throws DbException;
 
 	/**
 	 * Stores the given temporary secrets and deletes any secrets that have
@@ -146,14 +142,6 @@ interface Database<T> {
 	void addStatus(T txn, ContactId c, MessageId m, boolean seen)
 			throws DbException;
 
-	/**
-	 * Subscribes to the given group, or returns false if the user already has
-	 * the maximum number of subscriptions.
-	 * <p>
-	 * Locking: subscription write.
-	 */
-	boolean addSubscription(T txn, Group g) throws DbException;
-
 	/**
 	 * Stores a transport and returns true if the transport was not previously
 	 * in the database.
@@ -184,6 +172,13 @@ interface Database<T> {
 	 */
 	boolean containsContact(T txn, ContactId c) throws DbException;
 
+	/**
+	 * Returns true if the user subscribes to the given group.
+	 * <p>
+	 * Locking: subscription read.
+	 */
+	boolean containsGroup(T txn, GroupId g) throws DbException;
+
 	/**
 	 * Returns true if the database contains the given local pseudonym.
 	 * <p>
@@ -199,11 +194,11 @@ interface Database<T> {
 	boolean containsMessage(T txn, MessageId m) throws DbException;
 
 	/**
-	 * Returns true if the user subscribes to the given group.
+	 * Returns true if any messages are sendable to the given contact.
 	 * <p>
-	 * Locking: subscription read.
+	 * Locking: message read, subscription read.
 	 */
-	boolean containsSubscription(T txn, GroupId g) throws DbException;
+	boolean containsSendableMessages(T txn, ContactId c) throws DbException;
 
 	/**
 	 * Returns true if the database contains the given transport.
@@ -213,12 +208,12 @@ interface Database<T> {
 	boolean containsTransport(T txn, TransportId t) throws DbException;
 
 	/**
-	 * Returns true if the user subscribes to the given group and the
-	 * subscription is visible to the given contact.
+	 * Returns true if the user subscribes to the given group and the group is
+	 * visible to the given contact.
 	 * <p>
 	 * Locking: subscription read.
 	 */
-	boolean containsVisibleSubscription(T txn, ContactId c, GroupId g)
+	boolean containsVisibleGroup(T txn, ContactId c, GroupId g)
 			throws DbException;
 
 	/**
@@ -279,25 +274,34 @@ interface Database<T> {
 
 	/**
 	 * Returns the group with the given ID, if the user subscribes to it.
+	 * <p>
+	 * Locking: subscription read.
 	 */
 	Group getGroup(T txn, GroupId g) throws DbException;
 
 	/**
-	 * Returns the headers of all messages in the given group.
+	 * Returns all groups to which the user subscribes.
 	 * <p>
-	 * Locking: message read.
+	 * Locking: subscription read.
 	 */
-	Collection<GroupMessageHeader> getGroupMessageHeaders(T txn, GroupId g)
-			throws DbException;
+	Collection<Group> getGroups(T txn) throws DbException;
 
 	/**
-	 * Returns the parent of the given group message, or null if either the
-	 * message has no parent, or the parent is absent from the database, or the
-	 * parent belongs to a different group.
+	 * Returns the ID of the inbox group for the given contact, or null if no
+	 * inbox group has been set.
 	 * <p>
-	 * Locking: message read.
+	 * Locking: contact read, subscription read.
 	 */
-	MessageId getGroupMessageParent(T txn, MessageId m) throws DbException;
+	GroupId getInboxGroup(T txn, ContactId c) throws DbException;
+
+	/**
+	 * Returns the headers of all messages in the inbox group for the given
+	 * contact, or null if no inbox group has been set.
+	 * <p>
+	 * Locking: contact read, identity read, message read, subscription read.
+	 */
+	Collection<MessageHeader> getInboxMessageHeaders(T txn, ContactId c)
+			throws DbException;
 
 	/**
 	 * Returns the time at which a connection to each contact was last opened
@@ -345,17 +349,16 @@ interface Database<T> {
 	byte[] getMessageBody(T txn, MessageId m) throws DbException;
 
 	/**
-	 * Returns the headers of all private messages to or from the given
-	 * contact.
+	 * Returns the headers of all messages in the given group.
 	 * <p>
-	 * Locking: contact read, identity read, message read.
+	 * Locking: message read.
 	 */
-	Collection<PrivateMessageHeader> getPrivateMessageHeaders(T txn,
-			ContactId c) throws DbException;
+	Collection<MessageHeader> getMessageHeaders(T txn, GroupId g)
+			throws DbException;
 
 	/**
-	 * Returns the IDs of some messages received from the given contact that
-	 * need to be acknowledged, up to the given number of messages.
+	 * Returns the IDs of messages received from the given contact that need
+	 * to be acknowledged, up to the given number of messages.
 	 * <p>
 	 * Locking: message read.
 	 */
@@ -371,6 +374,23 @@ interface Database<T> {
 	Collection<MessageId> getMessagesToOffer(T txn, ContactId c,
 			int maxMessages) throws DbException;
 
+	/**
+	 * Returns the IDs of the oldest messages in the database, with a total
+	 * size less than or equal to the given size.
+	 * <p>
+	 * Locking: message read.
+	 */
+	Collection<MessageId> getOldMessages(T txn, int size) throws DbException;
+
+	/**
+	 * Returns the parent of the given message, or null if either the message
+	 * has no parent, or the parent is absent from the database, or the parent
+	 * belongs to a different group.
+	 * <p>
+	 * Locking: message read.
+	 */
+	MessageId getParent(T txn, MessageId m) throws DbException;
+
 	/**
 	 * Returns the message identified by the given ID, in serialised form.
 	 * <p>
@@ -388,14 +408,6 @@ interface Database<T> {
 	byte[] getRawMessageIfSendable(T txn, ContactId c, MessageId m)
 			throws DbException;
 
-	/**
-	 * Returns the IDs of the oldest messages in the database, with a total
-	 * size less than or equal to the given size.
-	 * <p>
-	 * Locking: message read.
-	 */
-	Collection<MessageId> getOldMessages(T txn, int size) throws DbException;
-
 	/**
 	 * Returns true if the given message has been read.
 	 * <p>
@@ -444,20 +456,6 @@ interface Database<T> {
 	Collection<MessageId> getSendableMessages(T txn, ContactId c, int maxLength)
 			throws DbException;
 
-	/**
-	 * Returns the groups to which the user subscribes.
-	 * <p>
-	 * Locking: subscription read.
-	 */
-	Collection<Group> getSubscriptions(T txn) throws DbException;
-
-	/**
-	 * Returns the groups to which the given contact subscribes.
-	 * <p>
-	 * Locking: subscription read.
-	 */
-	Collection<Group> getSubscriptions(T txn, ContactId c) throws DbException;
-
 	/**
 	 * Returns a subscription ack for the given contact, or null if no ack is
 	 * due.
@@ -518,27 +516,21 @@ interface Database<T> {
 	Map<GroupId, Integer> getUnreadMessageCounts(T txn) throws DbException;
 
 	/**
-	 * Returns the contacts to which the given group is visible.
+	 * Returns the IDs of all contacts to which the given group is visible.
 	 * <p>
 	 * Locking: subscription read.
 	 */
 	Collection<ContactId> getVisibility(T txn, GroupId g) throws DbException;
 
 	/**
-	 * Returns the subscriptions that are visible to the given contact.
+	 * Returns the IDs of all private groups that are visible to the given
+	 * contact.
 	 * <p>
 	 * Locking: subscription read.
 	 */
-	Collection<GroupId> getVisibleSubscriptions(T txn, ContactId c)
+	Collection<GroupId> getVisiblePrivateGroups(T txn, ContactId c)
 			throws DbException;
 
-	/**
-	 * Returns true if any messages are sendable to the given contact.
-	 * <p>
-	 * Locking: message read, subscription read.
-	 */
-	boolean hasSendableMessages(T txn, ContactId c) throws DbException;
-
 	/**
 	 * Increments the outgoing connection counter for the given endpoint
 	 * in the given rotation period and returns the old value, or -1 if the
@@ -576,7 +568,7 @@ interface Database<T> {
 			throws DbException;
 
 	/**
-	 * Removes a contact (and all associated state) from the database.
+	 * Removes a contact from the database.
 	 * <p>
 	 * Locking: contact write, message write, retention write,
 	 * subscription write, transport write, window write.
@@ -584,8 +576,16 @@ interface Database<T> {
 	void removeContact(T txn, ContactId c) throws DbException;
 
 	/**
-	 * Removes the local pseudonym with the given ID (and all associated
-	 * state) from the database.
+	 * Unsubscribes from a group. Any messages belonging to the group are
+	 * deleted from the database.
+	 * <p>
+	 * Locking: message write, subscription write.
+	 */
+	void removeGroup(T txn, GroupId g) throws DbException;
+
+	/**
+	 * Removes a local pseudonym (and all associated contacts) from the
+	 * database.
 	 * <p>
 	 * Locking: contact write, identity write, message write, retention write,
 	 * subscription write, transport write, window write.
@@ -608,23 +608,6 @@ interface Database<T> {
 	void removeMessagesToAck(T txn, ContactId c, Collection<MessageId> acked)
 			throws DbException;
 
-	/**
-	 * Marks any of the given messages that are considered outstanding with
-	 * respect to the given contact as seen by the contact.
-	 * <p>
-	 * Locking: message write.
-	 */
-	void removeOutstandingMessages(T txn, ContactId c,
-			Collection<MessageId> acked) throws DbException;
-
-	/**
-	 * Unsubscribes from the given group. Any messages belonging to the group
-	 * are deleted from the database.
-	 * <p>
-	 * Locking: message write, subscription write.
-	 */
-	void removeSubscription(T txn, GroupId g) throws DbException;
-
 	/**
 	 * Removes a transport (and all associated state) from the database.
 	 * <p>
@@ -648,6 +631,24 @@ interface Database<T> {
 	void setConnectionWindow(T txn, ContactId c, TransportId t, long period,
 			long centre, byte[] bitmap) throws DbException;
 
+	/**
+	 * Updates the groups to which the given contact subscribes and returns
+	 * true, unless an update with an equal or higher version number has
+	 * already been received from the contact.
+	 * <p>
+	 * Locking: subscription write.
+	 */
+	boolean setGroups(T txn, ContactId c, Collection<Group> groups,
+			long version) throws DbException;
+
+	/**
+	 * Makes a private group visible to the given contact, adds it to the
+	 * contact's subscriptions, and sets it as the inbox group for the contact.
+	 * <p>
+	 * Locking: contact read, message write, subscription write.
+	 */
+	public void setInboxGroup(T txn, ContactId c, Group g) throws DbException;
+
 	/**
 	 * Sets the time at which a connection to the given contact was last made.
 	 * <p>
@@ -656,8 +657,8 @@ interface Database<T> {
 	void setLastConnected(T txn, ContactId c, long now) throws DbException;
 
 	/**
-	 * Marks the given message read or unread and returns true if it was
-	 * previously read.
+	 * Marks a message read or unread and returns true if it was previously
+	 * read.
 	 * <p>
 	 * Locking: message write.
 	 */
@@ -703,16 +704,6 @@ interface Database<T> {
 	boolean setStatusSeenIfVisible(T txn, ContactId c, MessageId m)
 			throws DbException;
 
-	/**
-	 * Updates the groups to which the given contact subscribes and returns
-	 * true, unless an update with an equal or higher version number has
-	 * already been received from the contact.
-	 * <p>
-	 * Locking: subscription write.
-	 */
-	boolean setSubscriptions(T txn, ContactId c, Collection<Group> subs,
-			long version) throws DbException;
-
 	/**
 	 * Records a retention ack from the given contact for the given version,
 	 * unless the contact has already acked an equal or higher version.
diff --git a/briar-core/src/net/sf/briar/db/DatabaseComponentImpl.java b/briar-core/src/net/sf/briar/db/DatabaseComponentImpl.java
index d7c45f52a4..9999486989 100644
--- a/briar-core/src/net/sf/briar/db/DatabaseComponentImpl.java
+++ b/briar-core/src/net/sf/briar/db/DatabaseComponentImpl.java
@@ -2,7 +2,6 @@ package net.sf.briar.db;
 
 import static java.util.logging.Level.INFO;
 import static java.util.logging.Level.WARNING;
-import static net.sf.briar.api.transport.TransportConstants.MAX_CLOCK_DIFFERENCE;
 import static net.sf.briar.db.DatabaseConstants.BYTES_PER_SWEEP;
 import static net.sf.briar.db.DatabaseConstants.CRITICAL_FREE_SPACE;
 import static net.sf.briar.db.DatabaseConstants.MAX_BYTES_BETWEEN_SPACE_CHECKS;
@@ -37,26 +36,24 @@ import net.sf.briar.api.db.AckAndRequest;
 import net.sf.briar.api.db.ContactExistsException;
 import net.sf.briar.api.db.DatabaseComponent;
 import net.sf.briar.api.db.DbException;
-import net.sf.briar.api.db.GroupMessageHeader;
 import net.sf.briar.api.db.LocalAuthorExistsException;
+import net.sf.briar.api.db.MessageHeader;
 import net.sf.briar.api.db.NoSuchContactException;
 import net.sf.briar.api.db.NoSuchLocalAuthorException;
 import net.sf.briar.api.db.NoSuchMessageException;
 import net.sf.briar.api.db.NoSuchSubscriptionException;
 import net.sf.briar.api.db.NoSuchTransportException;
-import net.sf.briar.api.db.PrivateMessageHeader;
 import net.sf.briar.api.db.event.ContactAddedEvent;
 import net.sf.briar.api.db.event.ContactRemovedEvent;
 import net.sf.briar.api.db.event.DatabaseEvent;
 import net.sf.briar.api.db.event.DatabaseListener;
-import net.sf.briar.api.db.event.GroupMessageAddedEvent;
 import net.sf.briar.api.db.event.LocalAuthorAddedEvent;
 import net.sf.briar.api.db.event.LocalAuthorRemovedEvent;
 import net.sf.briar.api.db.event.LocalSubscriptionsUpdatedEvent;
 import net.sf.briar.api.db.event.LocalTransportsUpdatedEvent;
+import net.sf.briar.api.db.event.MessageAddedEvent;
 import net.sf.briar.api.db.event.MessageExpiredEvent;
 import net.sf.briar.api.db.event.MessageReceivedEvent;
-import net.sf.briar.api.db.event.PrivateMessageAddedEvent;
 import net.sf.briar.api.db.event.RemoteRetentionTimeUpdatedEvent;
 import net.sf.briar.api.db.event.RemoteSubscriptionsUpdatedEvent;
 import net.sf.briar.api.db.event.RemoteTransportsUpdatedEvent;
@@ -274,6 +271,31 @@ DatabaseCleaner.Callback {
 		}
 	}
 
+	public boolean addGroup(Group g) throws DbException {
+		boolean added = false;
+		messageLock.writeLock().lock();
+		try {
+			subscriptionLock.writeLock().lock();
+			try {
+				T txn = db.startTransaction();
+				try {
+					if(!db.containsGroup(txn, g.getId()))
+						added = db.addGroup(txn, g);
+					db.commitTransaction(txn);
+				} catch(DbException e) {
+					db.abortTransaction(txn);
+					throw e;
+				}
+			} finally {
+				subscriptionLock.writeLock().unlock();
+			}
+		} finally {
+			messageLock.writeLock().unlock();
+		}
+		if(added) callListeners(new SubscriptionAddedEvent(g));
+		return added;
+	}
+
 	public void addLocalAuthor(LocalAuthor a) throws DbException {
 		contactLock.writeLock().lock();
 		try {
@@ -323,8 +345,8 @@ DatabaseCleaner.Callback {
 		callListeners(new LocalAuthorAddedEvent(a.getId()));
 	}
 
-	public void addLocalGroupMessage(Message m) throws DbException {
-		boolean added = false;
+	public void addLocalMessage(Message m) throws DbException {
+		boolean duplicate;
 		contactLock.readLock().lock();
 		try {
 			messageLock.writeLock().lock();
@@ -333,11 +355,12 @@ DatabaseCleaner.Callback {
 				try {
 					T txn = db.startTransaction();
 					try {
-						// Don't store the message if the user has
-						// unsubscribed from the group
-						GroupId g = m.getGroup().getId();
-						if(db.containsSubscription(txn, g))
-							added = storeGroupMessage(txn, m, null);
+						duplicate = db.containsMessage(txn, m.getId());
+						if(!duplicate) {
+							GroupId g = m.getGroup().getId();
+							if(db.containsGroup(txn, g))
+								addMessage(txn, m, null);
+						}
 						db.commitTransaction(txn);
 					} catch(DbException e) {
 						db.abortTransaction(txn);
@@ -352,91 +375,30 @@ DatabaseCleaner.Callback {
 		} finally {
 			contactLock.readLock().unlock();
 		}
-		if(added)
-			callListeners(new GroupMessageAddedEvent(m.getGroup(), false));
+		if(!duplicate)
+			callListeners(new MessageAddedEvent(m.getGroup(), null));
 	}
 
 	/**
-	 * If the given message is already in the database, marks it as seen by the
-	 * sender and returns false. Otherwise stores the message, marks it as seen
-	 * by the sender and unseen by all other contacts, and returns true.
+	 * Stores the given message, marks it as read if it was locally generated,
+	 * otherwise marks it as seen by the sender, and marks it as unseen by all
+	 * other contacts.
 	 * <p>
-	 * Locking: contact read, message write.
-	 * @param sender is null for a locally generated message.
+	 * Locking: contact read, message write, subscription read.
+	 * @param sender null for a locally generated message.
 	 */
-	private boolean storeGroupMessage(T txn, Message m, ContactId sender)
+	private void addMessage(T txn, Message m, ContactId sender)
 			throws DbException {
-		if(m.getGroup() == null) throw new IllegalArgumentException();
-		boolean stored = db.addGroupMessage(txn, m, sender != null);
-		if(stored && sender == null) db.setReadFlag(txn, m.getId(), true);
-		// Mark the message as seen by the sender
+		db.addMessage(txn, m, sender != null);
 		MessageId id = m.getId();
-		if(sender != null) db.addStatus(txn, sender, id, true);
-		if(stored) {
-			// Mark the message as unseen by other contacts
-			for(ContactId c : db.getContactIds(txn))
-				if(!c.equals(sender)) db.addStatus(txn, c, id, false);
-			// Count the bytes stored
-			synchronized(spaceLock) {
-				bytesStoredSinceLastCheck += m.getSerialised().length;
-			}
-		} else {
-			if(LOG.isLoggable(INFO))
-				LOG.info("Duplicate group message not stored");
-		}
-		return stored;
-	}
-
-	public void addLocalPrivateMessage(Message m, ContactId c)
-			throws DbException {
-		boolean added;
-		contactLock.readLock().lock();
-		try {
-			messageLock.writeLock().lock();
-			try {
-				T txn = db.startTransaction();
-				try {
-					if(!db.containsContact(txn, c))
-						throw new NoSuchContactException();
-					added = storePrivateMessage(txn, m, c, false);
-					db.commitTransaction(txn);
-				} catch(DbException e) {
-					db.abortTransaction(txn);
-					throw e;
-				}
-			} finally {
-				messageLock.writeLock().unlock();
-			}
-		} finally {
-			contactLock.readLock().unlock();
-		}
-		if(added) callListeners(new PrivateMessageAddedEvent(c, false));
-	}
-
-	/**
-	 * If the given message is already in the database, returns false.
-	 * Otherwise stores the message and marks it as seen or unseen with respect
-	 * to the given contact, depending on whether the message is incoming or
-	 * outgoing, respectively.
-	 * <p>
-	 * Locking: message write.
-	 */
-	private boolean storePrivateMessage(T txn, Message m, ContactId c,
-			boolean incoming) throws DbException {
-		if(m.getGroup() != null) throw new IllegalArgumentException();
-		if(m.getAuthor() != null) throw new IllegalArgumentException();
-		if(!db.addPrivateMessage(txn, m, c, incoming)) {
-			if(LOG.isLoggable(INFO))
-				LOG.info("Duplicate private message not stored");
-			return false;
-		}
-		if(!incoming) db.setReadFlag(txn, m.getId(), true);
-		db.addStatus(txn, c, m.getId(), incoming);
+		if(sender == null) db.setReadFlag(txn, id, true);
+		else db.addStatus(txn, sender, id, true);
+		for(ContactId c : db.getContactIds(txn))
+			if(!c.equals(sender)) db.addStatus(txn, c, id, false);
 		// Count the bytes stored
 		synchronized(spaceLock) {
 			bytesStoredSinceLastCheck += m.getSerialised().length;
 		}
-		return true;
 	}
 
 	public void addSecrets(Collection<TemporarySecret> secrets)
@@ -500,6 +462,35 @@ DatabaseCleaner.Callback {
 		return added;
 	}
 
+	public boolean containsSendableMessages(ContactId c) throws DbException {
+		contactLock.readLock().lock();
+		try {
+			messageLock.readLock().lock();
+			try {
+				subscriptionLock.readLock().lock();
+				try {
+					T txn = db.startTransaction();
+					try {
+						if(!db.containsContact(txn, c))
+							throw new NoSuchContactException();
+						boolean has = db.containsSendableMessages(txn, c);
+						db.commitTransaction(txn);
+						return has;
+					} catch(DbException e) {
+						db.abortTransaction(txn);
+						throw e;
+					}
+				} finally {
+					subscriptionLock.readLock().unlock();
+				}
+			} finally {
+				messageLock.readLock().unlock();
+			}
+		} finally {
+			contactLock.readLock().unlock();
+		}
+	}
+
 	public Ack generateAck(ContactId c, int maxMessages) throws DbException {
 		Collection<MessageId> acked;
 		contactLock.readLock().lock();
@@ -924,7 +915,7 @@ DatabaseCleaner.Callback {
 		try {
 			T txn = db.startTransaction();
 			try {
-				if(!db.containsSubscription(txn, g))
+				if(!db.containsGroup(txn, g))
 					throw new NoSuchSubscriptionException();
 				Group group = db.getGroup(txn, g);
 				db.commitTransaction(txn);
@@ -938,20 +929,35 @@ DatabaseCleaner.Callback {
 		}
 	}
 
-	public Collection<GroupMessageHeader> getGroupMessageHeaders(GroupId g)
-			throws DbException {
-		messageLock.readLock().lock();
+	public Collection<Group> getGroups() throws DbException {
+		subscriptionLock.readLock().lock();
+		try {
+			T txn = db.startTransaction();
+			try {
+				Collection<Group> groups = db.getGroups(txn);
+				db.commitTransaction(txn);
+				return groups;
+			} catch(DbException e) {
+				db.abortTransaction(txn);
+				throw e;
+			}
+		} finally {
+			subscriptionLock.readLock().unlock();
+		}
+	}
+
+	public GroupId getInboxGroup(ContactId c) throws DbException {
+		contactLock.readLock().lock();
 		try {
 			subscriptionLock.readLock().lock();
 			try {
 				T txn = db.startTransaction();
 				try {
-					if(!db.containsSubscription(txn, g))
-						throw new NoSuchSubscriptionException();
-					Collection<GroupMessageHeader> headers =
-							db.getGroupMessageHeaders(txn, g);
+					if(!db.containsContact(txn, c))
+						throw new NoSuchContactException();
+					GroupId inbox = db.getInboxGroup(txn, c);
 					db.commitTransaction(txn);
-					return headers;
+					return inbox;
 				} catch(DbException e) {
 					db.abortTransaction(txn);
 					throw e;
@@ -960,7 +966,43 @@ DatabaseCleaner.Callback {
 				subscriptionLock.readLock().unlock();
 			}
 		} finally {
-			messageLock.readLock().unlock();
+			contactLock.readLock().unlock();
+		}
+	}
+
+	public Collection<MessageHeader> getInboxMessageHeaders(ContactId c)
+			throws DbException {
+		contactLock.readLock().lock();
+		try {
+			identityLock.readLock().lock();
+			try {
+				messageLock.readLock().lock();
+				try {
+					subscriptionLock.readLock().lock();
+					try {
+						T txn = db.startTransaction();
+						try {
+							if(!db.containsContact(txn, c))
+								throw new NoSuchContactException();
+							Collection<MessageHeader> headers =
+									db.getInboxMessageHeaders(txn, c);
+							db.commitTransaction(txn);
+							return headers;
+						} catch(DbException e) {
+							db.abortTransaction(txn);
+							throw e;
+						}
+					} finally {
+						subscriptionLock.readLock().unlock();
+					}
+				} finally {
+					messageLock.readLock().unlock();
+				}
+			} finally {
+				identityLock.readLock().unlock();
+			}
+		} finally {
+			contactLock.readLock().unlock();
 		}
 	}
 
@@ -1075,32 +1117,29 @@ DatabaseCleaner.Callback {
 		}
 	}
 
-	public Collection<PrivateMessageHeader> getPrivateMessageHeaders(
-			ContactId c) throws DbException {
-		contactLock.readLock().lock();
+	public Collection<MessageHeader> getMessageHeaders(GroupId g)
+			throws DbException {
+		messageLock.readLock().lock();
 		try {
-			identityLock.readLock().lock();
+			subscriptionLock.readLock().lock();
 			try {
-				messageLock.readLock().lock();
+				T txn = db.startTransaction();
 				try {
-					T txn = db.startTransaction();
-					try {
-						Collection<PrivateMessageHeader> headers =
-								db.getPrivateMessageHeaders(txn, c);
-						db.commitTransaction(txn);
-						return headers;
-					} catch(DbException e) {
-						db.abortTransaction(txn);
-						throw e;
-					}
-				} finally {
-					messageLock.readLock().unlock();
+					if(!db.containsGroup(txn, g))
+						throw new NoSuchSubscriptionException();
+					Collection<MessageHeader> headers =
+							db.getMessageHeaders(txn, g);
+					db.commitTransaction(txn);
+					return headers;
+				} catch(DbException e) {
+					db.abortTransaction(txn);
+					throw e;
 				}
 			} finally {
-				identityLock.readLock().unlock();
+				subscriptionLock.readLock().unlock();
 			}
 		} finally {
-			contactLock.readLock().unlock();
+			messageLock.readLock().unlock();
 		}
 	}
 
@@ -1159,23 +1198,6 @@ DatabaseCleaner.Callback {
 		}
 	}
 
-	public Collection<Group> getSubscriptions() throws DbException {
-		subscriptionLock.readLock().lock();
-		try {
-			T txn = db.startTransaction();
-			try {
-				Collection<Group> subs = db.getSubscriptions(txn);
-				db.commitTransaction(txn);
-				return subs;
-			} catch(DbException e) {
-				db.abortTransaction(txn);
-				throw e;
-			}
-		} finally {
-			subscriptionLock.readLock().unlock();
-		}
-	}
-
 	public Map<TransportId, Long> getTransportLatencies() throws DbException {
 		transportLock.readLock().lock();
 		try {
@@ -1216,7 +1238,7 @@ DatabaseCleaner.Callback {
 		try {
 			T txn = db.startTransaction();
 			try {
-				if(!db.containsSubscription(txn, g))
+				if(!db.containsGroup(txn, g))
 					throw new NoSuchSubscriptionException();
 				Collection<ContactId> visible = db.getVisibility(txn, g);
 				db.commitTransaction(txn);
@@ -1230,61 +1252,6 @@ DatabaseCleaner.Callback {
 		}
 	}
 
-	public Collection<GroupId> getVisibleSubscriptions(ContactId c)
-			throws DbException {
-		contactLock.readLock().lock();
-		try {
-			subscriptionLock.readLock().lock();
-			try {
-				T txn = db.startTransaction();
-				try {
-					if(!db.containsContact(txn, c))
-						throw new NoSuchContactException();
-					Collection<GroupId> visible =
-							db.getVisibleSubscriptions(txn, c);
-					db.commitTransaction(txn);
-					return visible;
-				} catch(DbException e) {
-					db.abortTransaction(txn);
-					throw e;
-				}
-			} finally {
-				subscriptionLock.readLock().unlock();
-			}
-		} finally {
-			contactLock.readLock().unlock();
-		}
-	}
-
-	public boolean hasSendableMessages(ContactId c) throws DbException {
-		contactLock.readLock().lock();
-		try {
-			messageLock.readLock().lock();
-			try {
-				subscriptionLock.readLock().lock();
-				try {
-					T txn = db.startTransaction();
-					try {
-						if(!db.containsContact(txn, c))
-							throw new NoSuchContactException();
-						boolean has = db.hasSendableMessages(txn, c);
-						db.commitTransaction(txn);
-						return has;
-					} catch(DbException e) {
-						db.abortTransaction(txn);
-						throw e;
-					}
-				} finally {
-					subscriptionLock.readLock().unlock();
-				}
-			} finally {
-				messageLock.readLock().unlock();
-			}
-		} finally {
-			contactLock.readLock().unlock();
-		}
-	}
-
 	public long incrementConnectionCounter(ContactId c, TransportId t,
 			long period) throws DbException {
 		contactLock.readLock().lock();
@@ -1374,7 +1341,8 @@ DatabaseCleaner.Callback {
 				try {
 					if(!db.containsContact(txn, c))
 						throw new NoSuchContactException();
-					db.removeOutstandingMessages(txn, c, a.getMessageIds());
+					for(MessageId m : a.getMessageIds())
+						db.setStatusSeenIfVisible(txn, c, m);
 					db.commitTransaction(txn);
 				} catch(DbException e) {
 					db.abortTransaction(txn);
@@ -1389,7 +1357,7 @@ DatabaseCleaner.Callback {
 	}
 
 	public void receiveMessage(ContactId c, Message m) throws DbException {
-		boolean added = false;
+		boolean duplicate, visible;
 		contactLock.readLock().lock();
 		try {
 			messageLock.writeLock().lock();
@@ -1400,8 +1368,11 @@ DatabaseCleaner.Callback {
 					try {
 						if(!db.containsContact(txn, c))
 							throw new NoSuchContactException();
-						added = storeMessage(txn, c, m);
-						db.addMessageToAck(txn, c, m.getId());
+						duplicate = db.containsMessage(txn, m.getId());
+						GroupId g = m.getGroup().getId();
+						visible = db.containsVisibleGroup(txn, c, g);
+						if(!duplicate && visible) addMessage(txn, m, c);
+						if(visible) db.addMessageToAck(txn, c, m.getId());
 						db.commitTransaction(txn);
 					} catch(DbException e) {
 						db.abortTransaction(txn);
@@ -1416,36 +1387,8 @@ DatabaseCleaner.Callback {
 		} finally {
 			contactLock.readLock().unlock();
 		}
-		callListeners(new MessageReceivedEvent(c));
-		if(added) {
-			Group g = m.getGroup();
-			if(g == null) callListeners(new PrivateMessageAddedEvent(c, true));
-			else callListeners(new GroupMessageAddedEvent(g, true));
-		}
-	}
-
-	/**
-	 * Attempts to store a message received from the given contact, and returns
-	 * true if it was stored.
-	 * <p>
-	 * Locking: contact read, message write, subscription read.
-	 */
-	private boolean storeMessage(T txn, ContactId c, Message m)
-			throws DbException {
-		long now = clock.currentTimeMillis();
-		if(m.getTimestamp() > now + MAX_CLOCK_DIFFERENCE) {
-			if(LOG.isLoggable(INFO))
-				LOG.info("Discarding message with future timestamp");
-			return false;
-		}
-		Group g = m.getGroup();
-		if(g == null) return storePrivateMessage(txn, m, c, true);
-		if(!db.containsVisibleSubscription(txn, c, g.getId())) {
-			if(LOG.isLoggable(INFO))
-				LOG.info("Discarding message without visible subscription");
-			return false;
-		}
-		return storeGroupMessage(txn, m, c);
+		if(visible) callListeners(new MessageReceivedEvent(c));
+		if(!duplicate) callListeners(new MessageAddedEvent(m.getGroup(), c));
 	}
 
 	public AckAndRequest receiveOffer(ContactId c, Offer o) throws DbException {
@@ -1573,7 +1516,7 @@ DatabaseCleaner.Callback {
 						throw new NoSuchContactException();
 					Collection<Group> groups = u.getGroups();
 					long version = u.getVersion();
-					updated = db.setSubscriptions(txn, c, groups, version);
+					updated = db.setGroups(txn, c, groups, version);
 					db.commitTransaction(txn);
 				} catch(DbException e) {
 					db.abortTransaction(txn);
@@ -1689,6 +1632,34 @@ DatabaseCleaner.Callback {
 		callListeners(new ContactRemovedEvent(c));
 	}
 
+	public void removeGroup(Group g) throws DbException {
+		Collection<ContactId> affected;
+		messageLock.writeLock().lock();
+		try {
+			subscriptionLock.writeLock().lock();
+			try {
+				T txn = db.startTransaction();
+				try {
+					GroupId id = g.getId();
+					if(!db.containsGroup(txn, id))
+						throw new NoSuchSubscriptionException();
+					affected = db.getVisibility(txn, id);
+					db.removeGroup(txn, id);
+					db.commitTransaction(txn);
+				} catch(DbException e) {
+					db.abortTransaction(txn);
+					throw e;
+				}
+			} finally {
+				subscriptionLock.writeLock().unlock();
+			}
+		} finally {
+			messageLock.writeLock().unlock();
+		}
+		callListeners(new SubscriptionRemovedEvent(g));
+		callListeners(new LocalSubscriptionsUpdatedEvent(affected));
+	}
+
 	public void removeLocalAuthor(AuthorId a) throws DbException {
 		Collection<ContactId> affected;
 		contactLock.writeLock().lock();
@@ -1798,6 +1769,35 @@ DatabaseCleaner.Callback {
 		}
 	}
 
+	public void setInboxGroup(ContactId c, Group g) throws DbException {
+		if(!g.isPrivate()) throw new IllegalArgumentException();
+		contactLock.readLock().lock();
+		try {
+			messageLock.writeLock().lock();
+			try {
+				subscriptionLock.writeLock().lock();
+				try {
+					T txn = db.startTransaction();
+					try {
+						if(!db.containsContact(txn, c))
+							throw new NoSuchContactException();
+						db.setInboxGroup(txn, c, g);
+						db.commitTransaction(txn);
+					} catch(DbException e) {
+						db.abortTransaction(txn);
+						throw e;
+					}
+				} finally {
+					subscriptionLock.writeLock().unlock();
+				}
+			} finally {
+				messageLock.writeLock().unlock();
+			}
+		} finally {
+			contactLock.readLock().unlock();
+		}
+	}
+
 	public boolean setReadFlag(MessageId m, boolean read) throws DbException {
 		messageLock.writeLock().lock();
 		try {
@@ -1880,7 +1880,7 @@ DatabaseCleaner.Callback {
 			try {
 				T txn = db.startTransaction();
 				try {
-					if(!db.containsSubscription(txn, g))
+					if(!db.containsGroup(txn, g))
 						throw new NoSuchSubscriptionException();
 					// Use HashSets for O(1) lookups, O(n) overall running time
 					HashSet<ContactId> now = new HashSet<ContactId>(visible);
@@ -1923,7 +1923,7 @@ DatabaseCleaner.Callback {
 			try {
 				T txn = db.startTransaction();
 				try {
-					if(!db.containsSubscription(txn, g))
+					if(!db.containsGroup(txn, g))
 						throw new NoSuchSubscriptionException();
 					// Make the group visible or invisible to future contacts
 					db.setVisibleToAll(txn, g, all);
@@ -1953,59 +1953,6 @@ DatabaseCleaner.Callback {
 			callListeners(new LocalSubscriptionsUpdatedEvent(affected));
 	}
 
-	public boolean subscribe(Group g) throws DbException {
-		boolean added = false;
-		subscriptionLock.writeLock().lock();
-		try {
-			T txn = db.startTransaction();
-			try {
-				if(!db.containsSubscription(txn, g.getId()))
-					added = db.addSubscription(txn, g);
-				db.commitTransaction(txn);
-			} catch(DbException e) {
-				db.abortTransaction(txn);
-				throw e;
-			}
-		} finally {
-			subscriptionLock.writeLock().unlock();
-		}
-		if(added) callListeners(new SubscriptionAddedEvent(g));
-		return added;
-	}
-
-	public void unsubscribe(Group g) throws DbException {
-		Collection<ContactId> affected;
-		identityLock.writeLock().lock();
-		try {
-			messageLock.writeLock().lock();
-			try {
-				subscriptionLock.writeLock().lock();
-				try {
-					T txn = db.startTransaction();
-					try {
-						GroupId id = g.getId();
-						if(!db.containsSubscription(txn, id))
-							throw new NoSuchSubscriptionException();
-						affected = db.getVisibility(txn, id);
-						db.removeSubscription(txn, id);
-						db.commitTransaction(txn);
-					} catch(DbException e) {
-						db.abortTransaction(txn);
-						throw e;
-					}
-				} finally {
-					subscriptionLock.writeLock().unlock();
-				}
-			} finally {
-				messageLock.writeLock().unlock();
-			}
-		} finally {
-			identityLock.writeLock().unlock();
-		}
-		callListeners(new SubscriptionRemovedEvent(g));
-		callListeners(new LocalSubscriptionsUpdatedEvent(affected));
-	}
-
 	public void checkFreeSpaceAndClean() throws DbException {
 		long freeSpace = db.getFreeSpace();
 		if(LOG.isLoggable(INFO)) LOG.info(freeSpace + " bytes free space");
diff --git a/briar-core/src/net/sf/briar/db/JdbcDatabase.java b/briar-core/src/net/sf/briar/db/JdbcDatabase.java
index 2c432b5c66..6fe38d2d25 100644
--- a/briar-core/src/net/sf/briar/db/JdbcDatabase.java
+++ b/briar-core/src/net/sf/briar/db/JdbcDatabase.java
@@ -18,12 +18,10 @@ import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
-import java.util.Set;
 import java.util.logging.Logger;
 
 import net.sf.briar.api.Author;
@@ -37,8 +35,7 @@ import net.sf.briar.api.TransportProperties;
 import net.sf.briar.api.clock.Clock;
 import net.sf.briar.api.db.DbClosedException;
 import net.sf.briar.api.db.DbException;
-import net.sf.briar.api.db.GroupMessageHeader;
-import net.sf.briar.api.db.PrivateMessageHeader;
+import net.sf.briar.api.db.MessageHeader;
 import net.sf.briar.api.messaging.Group;
 import net.sf.briar.api.messaging.GroupId;
 import net.sf.briar.api.messaging.GroupStatus;
@@ -91,6 +88,7 @@ abstract class JdbcDatabase implements Database<Connection> {
 					+ " (groupId HASH NOT NULL,"
 					+ " name VARCHAR NOT NULL,"
 					+ " salt BINARY NOT NULL,"
+					+ " private BOOLEAN NOT NULL,"
 					+ " visibleToAll BOOLEAN NOT NULL,"
 					+ " PRIMARY KEY (groupId))";
 
@@ -99,6 +97,7 @@ abstract class JdbcDatabase implements Database<Connection> {
 			"CREATE TABLE groupVisibilities"
 					+ " (contactId INT NOT NULL,"
 					+ " groupId HASH NOT NULL,"
+					+ " inbox BOOLEAN NOT NULL,"
 					+ " FOREIGN KEY (contactId)"
 					+ " REFERENCES contacts (contactId)"
 					+ " ON DELETE CASCADE,"
@@ -113,6 +112,7 @@ abstract class JdbcDatabase implements Database<Connection> {
 					+ " groupId HASH NOT NULL," // Not a foreign key
 					+ " name VARCHAR NOT NULL,"
 					+ " salt BINARY NOT NULL,"
+					+ " private BOOLEAN NOT NULL,"
 					+ " PRIMARY KEY (contactId, groupId),"
 					+ " FOREIGN KEY (contactId)"
 					+ " REFERENCES contacts (contactId)"
@@ -138,7 +138,7 @@ abstract class JdbcDatabase implements Database<Connection> {
 			"CREATE TABLE messages"
 					+ " (messageId HASH NOT NULL,"
 					+ " parentId HASH," // Null for the first msg in a thread
-					+ " groupId HASH," // Null for private messages
+					+ " groupId HASH NOT NULL,"
 					+ " authorId HASH," // Null for private/anon messages
 					+ " authorName VARCHAR," // Null for private/anon messages
 					+ " authorKey VARCHAR," // Null for private/anon messages
@@ -149,14 +149,10 @@ abstract class JdbcDatabase implements Database<Connection> {
 					+ " bodyLength INT NOT NULL,"
 					+ " raw BLOB NOT NULL,"
 					+ " incoming BOOLEAN NOT NULL,"
-					+ " contactId INT UNSIGNED," // Null for group messages
 					+ " read BOOLEAN NOT NULL,"
 					+ " PRIMARY KEY (messageId),"
 					+ " FOREIGN KEY (groupId)"
 					+ " REFERENCES groups (groupId)"
-					+ " ON DELETE CASCADE,"
-					+ " FOREIGN KEY (contactId)"
-					+ " REFERENCES contacts (contactId)"
 					+ " ON DELETE CASCADE)";
 
 	private static final String INDEX_MESSAGES_BY_TIMESTAMP =
@@ -546,8 +542,9 @@ abstract class JdbcDatabase implements Database<Connection> {
 			rs.close();
 			ps.close();
 			if(!ids.isEmpty()) {
-				sql = "INSERT INTO groupVisibilities (contactId, groupId)"
-						+ " VALUES (?, ?)";
+				sql = "INSERT INTO groupVisibilities"
+						+ " (contactId, groupId, inbox)"
+						+ " VALUES (?, ?, FALSE)";
 				ps = txn.prepareStatement(sql);
 				ps.setInt(1, c.getInt());
 				for(byte[] id : ids) {
@@ -644,18 +641,80 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public boolean addGroupMessage(Connection txn, Message m, boolean incoming)
+	public boolean addGroup(Connection txn, Group g) throws DbException {
+		if(maximumSubscriptionsReached(txn)) return false;
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO groups"
+					+ " (groupId, name, salt, private, visibleToAll)"
+					+ " VALUES (?, ?, ?, ?, FALSE)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getId().getBytes());
+			ps.setString(2, g.getName());
+			ps.setBytes(3, g.getSalt());
+			ps.setBoolean(4, g.isPrivate());
+			int affected = ps.executeUpdate();
+			if(affected != 1) throw new DbStateException();
+			ps.close();
+			return true;
+		} catch(SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	private boolean maximumSubscriptionsReached(Connection txn)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT COUNT (groupId) FROM groups";
+			ps = txn.prepareStatement(sql);
+			rs = ps.executeQuery();
+			if(!rs.next()) throw new DbStateException();
+			int count = rs.getInt(1);
+			if(rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			if(count > MAX_SUBSCRIPTIONS) throw new DbStateException();
+			return count == MAX_SUBSCRIPTIONS;
+		} catch(SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	public void addLocalAuthor(Connection txn, LocalAuthor a)
+			throws DbException {
+		PreparedStatement ps = null;
+		try {
+			String sql = "INSERT INTO localAuthors"
+					+ " (authorId, name, publicKey, privateKey)"
+					+ " VALUES (?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, a.getId().getBytes());
+			ps.setString(2, a.getName());
+			ps.setBytes(3, a.getPublicKey());
+			ps.setBytes(4, a.getPrivateKey());
+			int affected = ps.executeUpdate();
+			if(affected != 1) throw new DbStateException();
+			ps.close();
+		} catch(SQLException e) {
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	public void addMessage(Connection txn, Message m, boolean incoming)
 			throws DbException {
-		if(m.getGroup() == null) throw new IllegalArgumentException();
-		if(containsMessage(txn, m.getId())) return false;
 		PreparedStatement ps = null;
 		try {
 			String sql = "INSERT INTO messages (messageId, parentId, groupId,"
 					+ " authorId, authorName, authorKey, contentType,"
 					+ " timestamp, length, bodyStart, bodyLength, raw,"
 					+ " incoming, read)"
-					+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,"
-					+ " FALSE)";
+					+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, FALSE)";
 			ps = txn.prepareStatement(sql);
 			ps.setBytes(1, m.getId().getBytes());
 			if(m.getParent() == null) ps.setNull(2, BINARY);
@@ -682,28 +741,6 @@ abstract class JdbcDatabase implements Database<Connection> {
 			int affected = ps.executeUpdate();
 			if(affected != 1) throw new DbStateException();
 			ps.close();
-			return true;
-		} catch(SQLException e) {
-			tryToClose(ps);
-			throw new DbException(e);
-		}
-	}
-
-	public void addLocalAuthor(Connection txn, LocalAuthor a)
-			throws DbException {
-		PreparedStatement ps = null;
-		try {
-			String sql = "INSERT INTO localAuthors"
-					+ " (authorId, name, publicKey, privateKey)"
-					+ " VALUES (?, ?, ?, ?)";
-			ps = txn.prepareStatement(sql);
-			ps.setBytes(1, a.getId().getBytes());
-			ps.setString(2, a.getName());
-			ps.setBytes(3, a.getPublicKey());
-			ps.setBytes(4, a.getPrivateKey());
-			int affected = ps.executeUpdate();
-			if(affected != 1) throw new DbStateException();
-			ps.close();
 		} catch(SQLException e) {
 			tryToClose(ps);
 			throw new DbException(e);
@@ -741,40 +778,6 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public boolean addPrivateMessage(Connection txn, Message m, ContactId c,
-			boolean incoming) throws DbException {
-		if(m.getGroup() != null) throw new IllegalArgumentException();
-		if(m.getAuthor() != null) throw new IllegalArgumentException();
-		if(containsMessage(txn, m.getId())) return false;
-		PreparedStatement ps = null;
-		try {
-			String sql = "INSERT INTO messages (messageId, parentId,"
-					+ " contentType, timestamp, length, bodyStart,"
-					+ " bodyLength, raw, incoming, contactId, read)"
-					+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, FALSE)";
-			ps = txn.prepareStatement(sql);
-			ps.setBytes(1, m.getId().getBytes());
-			if(m.getParent() == null) ps.setNull(2, BINARY);
-			else ps.setBytes(2, m.getParent().getBytes());
-			ps.setString(3, m.getContentType());
-			ps.setLong(4, m.getTimestamp());
-			byte[] raw = m.getSerialised();
-			ps.setInt(5, raw.length);
-			ps.setInt(6, m.getBodyStart());
-			ps.setInt(7, m.getBodyLength());
-			ps.setBytes(8, raw);
-			ps.setBoolean(9, incoming);
-			ps.setInt(10, c.getInt());
-			int affected = ps.executeUpdate();
-			if(affected != 1) throw new DbStateException();
-			ps.close();
-			return true;
-		} catch(SQLException e) {
-			tryToClose(ps);
-			throw new DbException(e);
-		}
-	}
-
 	public void addSecrets(Connection txn, Collection<TemporarySecret> secrets)
 			throws DbException {
 		PreparedStatement ps = null;
@@ -841,36 +844,6 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public boolean addSubscription(Connection txn, Group g) throws DbException {
-		PreparedStatement ps = null;
-		ResultSet rs = null;
-		try {
-			String sql = "SELECT COUNT (groupId) FROM groups";
-			ps = txn.prepareStatement(sql);
-			rs = ps.executeQuery();
-			if(!rs.next()) throw new DbStateException();
-			int count = rs.getInt(1);
-			if(rs.next()) throw new DbStateException();
-			rs.close();
-			ps.close();
-			if(count > MAX_SUBSCRIPTIONS) throw new DbStateException();
-			if(count == MAX_SUBSCRIPTIONS) return false;
-			sql = "INSERT INTO groups (groupId, name, salt, visibleToAll)"
-					+ " VALUES (?, ?, ?, FALSE)";
-			ps = txn.prepareStatement(sql);
-			ps.setBytes(1, g.getId().getBytes());
-			ps.setString(2, g.getName());
-			ps.setBytes(3, g.getSalt());
-			int affected = ps.executeUpdate();
-			if(affected != 1) throw new DbStateException();
-			ps.close();
-			return true;
-		} catch(SQLException e) {
-			tryToClose(ps);
-			throw new DbException(e);
-		}
-	}
-
 	public boolean addTransport(Connection txn, TransportId t, long maxLatency)
 			throws DbException {
 		PreparedStatement ps = null;
@@ -931,8 +904,9 @@ abstract class JdbcDatabase implements Database<Connection> {
 			throws DbException {
 		PreparedStatement ps = null;
 		try {
-			String sql = "INSERT INTO groupVisibilities (contactId, groupId)"
-					+ " VALUES (?, ?)";
+			String sql = "INSERT INTO groupVisibilities"
+					+ " (contactId, groupId, inbox)"
+					+ " VALUES (?, ?, FALSE)";
 			ps = txn.prepareStatement(sql);
 			ps.setInt(1, c.getInt());
 			ps.setBytes(2, g.getBytes());
@@ -997,6 +971,27 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
+	public boolean containsGroup(Connection txn, GroupId g)
+			throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT NULL FROM groups WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			rs = ps.executeQuery();
+			boolean found = rs.next();
+			if(rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return found;
+		} catch(SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
 	public boolean containsLocalAuthor(Connection txn, AuthorId a)
 			throws DbException {
 		PreparedStatement ps = null;
@@ -1039,14 +1034,29 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public boolean containsSubscription(Connection txn, GroupId g)
+	public boolean containsSendableMessages(Connection txn, ContactId c)
 			throws DbException {
+		long now = clock.currentTimeMillis();
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT NULL FROM groups WHERE groupId = ?";
+			String sql = "SELECT NULL FROM messages AS m"
+					+ " JOIN contactGroups AS cg"
+					+ " ON m.groupId = cg.groupId"
+					+ " JOIN groupVisibilities AS gv"
+					+ " ON m.groupId = gv.groupId"
+					+ " AND cg.contactId = gv.contactId"
+					+ " JOIN retentionVersions AS rv"
+					+ " ON cg.contactId = rv.contactId"
+					+ " JOIN statuses AS s"
+					+ " ON m.messageId = s.messageId"
+					+ " AND cg.contactId = s.contactId"
+					+ " WHERE cg.contactId = ?"
+					+ " AND timestamp >= retention"
+					+ " AND seen = FALSE AND s.expiry < ?";
 			ps = txn.prepareStatement(sql);
-			ps.setBytes(1, g.getBytes());
+			ps.setInt(1, c.getInt());
+			ps.setLong(2, now);
 			rs = ps.executeQuery();
 			boolean found = rs.next();
 			if(rs.next()) throw new DbStateException();
@@ -1081,7 +1091,7 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public boolean containsVisibleSubscription(Connection txn, ContactId c,
+	public boolean containsVisibleGroup(Connection txn, ContactId c,
 			GroupId g) throws DbException {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
@@ -1110,32 +1120,36 @@ abstract class JdbcDatabase implements Database<Connection> {
 		ResultSet rs = null;
 		try {
 			// Add all subscribed groups to the list
-			String sql = "SELECT groupId, name, salt, visibleToAll FROM groups";
+			String sql = "SELECT groupId, name, salt, private, visibleToAll"
+					+ " FROM groups";
 			ps = txn.prepareStatement(sql);
 			rs = ps.executeQuery();
 			List<GroupStatus> groups = new ArrayList<GroupStatus>();
-			Set<GroupId> subscribed = new HashSet<GroupId>();
 			while(rs.next()) {
 				GroupId id = new GroupId(rs.getBytes(1));
-				subscribed.add(id);
 				String name = rs.getString(2);
 				byte[] salt = rs.getBytes(3);
-				Group group = new Group(id, name, salt);
-				boolean visibleToAll = rs.getBoolean(4);
+				boolean isPrivate = rs.getBoolean(4);
+				Group group = new Group(id, name, salt, isPrivate);
+				boolean visibleToAll = rs.getBoolean(5);
 				groups.add(new GroupStatus(group, true, visibleToAll));
 			}
 			rs.close();
 			ps.close();
-			// Add all contact groups to the list, unless already added
-			sql = "SELECT DISTINCT groupId, name, salt FROM contactGroups";
+			// Add all unsubscribed groups to the list
+			sql = "SELECT DISTINCT cg.groupId, cg.name, cg.salt, cg.private"
+					+ " FROM contactGroups AS cg"
+					+ " LEFT OUTER JOIN groups AS g"
+					+ " ON cg.groupId = g.groupId"
+					+ " WHERE g.groupId IS NULL";
 			ps = txn.prepareStatement(sql);
 			rs = ps.executeQuery();
 			while(rs.next()) {
 				GroupId id = new GroupId(rs.getBytes(1));
-				if(subscribed.contains(id)) continue;
 				String name = rs.getString(2);
 				byte[] salt = rs.getBytes(3);
-				Group group = new Group(id, name, salt);
+				boolean isPrivate = rs.getBoolean(4);
+				Group group = new Group(id, name, salt, isPrivate);
 				groups.add(new GroupStatus(group, false, false));
 			}
 			rs.close();
@@ -1298,16 +1312,18 @@ abstract class JdbcDatabase implements Database<Connection> {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT name, salt FROM groups WHERE groupId = ?";
+			String sql = "SELECT name, salt, private FROM groups"
+					+ " WHERE groupId = ?";
 			ps = txn.prepareStatement(sql);
 			ps.setBytes(1, g.getBytes());
 			rs = ps.executeQuery();
 			if(!rs.next()) throw new DbStateException();
 			String name = rs.getString(1);
 			byte[] salt = rs.getBytes(2);
+			boolean isPrivate = rs.getBoolean(3);
 			rs.close();
 			ps.close();
-			return new Group(g, name, salt);
+			return new Group(g, name, salt, isPrivate);
 		} catch(SQLException e) {
 			tryToClose(rs);
 			tryToClose(ps);
@@ -1315,43 +1331,24 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public Collection<GroupMessageHeader> getGroupMessageHeaders(Connection txn,
-			GroupId g) throws DbException {
+	public Collection<Group> getGroups(Connection txn) throws DbException {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT messageId, parentId, authorId, authorName,"
-					+ " authorKey, contentType, timestamp, read"
-					+ " FROM messages"
-					+ " WHERE groupId = ?";
+			String sql = "SELECT groupId, name, salt, private FROM groups";
 			ps = txn.prepareStatement(sql);
-			ps.setBytes(1, g.getBytes());
 			rs = ps.executeQuery();
-			List<GroupMessageHeader> headers =
-					new ArrayList<GroupMessageHeader>();
+			List<Group> groups = new ArrayList<Group>();
 			while(rs.next()) {
-				MessageId id = new MessageId(rs.getBytes(1));
-				byte[] b = rs.getBytes(2);
-				MessageId parent = b == null ? null : new MessageId(b);
-				Author author;
-				b = rs.getBytes(3);
-				if(b == null) {
-					author = null;
-				} else {
-					AuthorId authorId = new AuthorId(b);
-					String authorName = rs.getString(4);
-					byte[] authorKey = rs.getBytes(5);
-					author = new Author(authorId, authorName, authorKey);
-				}
-				String contentType = rs.getString(6);
-				long timestamp = rs.getLong(7);
-				boolean read = rs.getBoolean(8);
-				headers.add(new GroupMessageHeader(id, parent, author,
-						contentType, timestamp, read, g));
+				GroupId id = new GroupId(rs.getBytes(1));
+				String name = rs.getString(2);
+				byte[] salt = rs.getBytes(3);
+				boolean isPrivate = rs.getBoolean(4);
+				groups.add(new Group(id, name, salt, isPrivate));
 			}
 			rs.close();
 			ps.close();
-			return Collections.unmodifiableList(headers);
+			return Collections.unmodifiableList(groups);
 		} catch(SQLException e) {
 			tryToClose(rs);
 			tryToClose(ps);
@@ -1359,27 +1356,87 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public MessageId getGroupMessageParent(Connection txn, MessageId m)
+	public GroupId getInboxGroup(Connection txn, ContactId c)
 			throws DbException {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT m1.parentId FROM messages AS m1"
-					+ " JOIN messages AS m2"
-					+ " ON m1.parentId = m2.messageId"
-					+ " AND m1.groupId = m2.groupId"
-					+ " WHERE m1.messageId = ?";
+			String sql = "SELECT groupId FROM groupVisibilities"
+					+ " WHERE contactId = ?"
+					+ " AND inbox = TRUE";
 			ps = txn.prepareStatement(sql);
-			ps.setBytes(1, m.getBytes());
+			ps.setInt(1, c.getInt());
 			rs = ps.executeQuery();
-			MessageId parent = null;
-			if(rs.next()) {
-				parent = new MessageId(rs.getBytes(1));
-				if(rs.next()) throw new DbStateException();
+			GroupId inbox = null;
+			if(rs.next()) inbox = new GroupId(rs.getBytes(1));
+			if(rs.next()) throw new DbStateException();
+			rs.close();
+			ps.close();
+			return inbox;
+		} catch(SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	public Collection<MessageHeader> getInboxMessageHeaders(Connection txn,
+			ContactId c) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Get the local and remote authors
+			String sql = "SELECT l.authorId, l.name, l.publicKey,"
+					+ " r.authorId, r.name, r.publicKay"
+					+ " FROM localAuthors AS l"
+					+ " JOIN contacts AS r"
+					+ " ON l.authorId = r.localAuthorId"
+					+ " WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			rs = ps.executeQuery();
+			AuthorId localId = new AuthorId(rs.getBytes(1));
+			String localName = rs.getString(2);
+			byte[] localKey = rs.getBytes(3);
+			Author localAuthor = new Author(localId, localName, localKey);
+			AuthorId remoteId = new AuthorId(rs.getBytes(4));
+			String remoteName = rs.getString(5);
+			byte[] remoteKey = rs.getBytes(6);
+			Author remoteAuthor = new Author(remoteId, remoteName, remoteKey);
+			// Get the message headers
+			sql = "SELECT messageId, parentId, m.groupId, contentType,"
+					+ " timestamp, incoming, read"
+					+ " FROM messages AS m"
+					+ " JOIN groups AS g"
+					+ " ON m.groupId = g.groupId"
+					+ " JOIN groupVisibilities AS gv"
+					+ " ON m.groupId = gv.groupId"
+					+ " WHERE gv.contactId = ?"
+					+ " AND inbox = TRUE";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			rs = ps.executeQuery();
+			List<MessageHeader> headers = new ArrayList<MessageHeader>();
+			while(rs.next()) {
+				MessageId id = new MessageId(rs.getBytes(1));
+				byte[] b = rs.getBytes(2);
+				MessageId parent = b == null ? null : new MessageId(b);
+				GroupId groupId = new GroupId(rs.getBytes(3));
+				String contentType = rs.getString(4);
+				long timestamp = rs.getLong(5);
+				boolean incoming = rs.getBoolean(6);
+				boolean read = rs.getBoolean(7);
+				if(incoming) {
+					headers.add(new MessageHeader(id, parent, groupId,
+							remoteAuthor, contentType, timestamp, read));
+				} else {
+					headers.add(new MessageHeader(id, parent, groupId,
+							localAuthor, contentType, timestamp, read));
+				}
 			}
 			rs.close();
 			ps.close();
-			return parent;
+			return Collections.unmodifiableList(headers);
 		} catch(SQLException e) {
 			tryToClose(rs);
 			tryToClose(ps);
@@ -1542,22 +1599,65 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public Collection<MessageId> getMessagesToAck(Connection txn, ContactId c,
-			int maxMessages) throws DbException {
+	public Collection<MessageHeader> getMessageHeaders(Connection txn,
+			GroupId g) throws DbException {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT messageId FROM messagesToAck"
-					+ " WHERE contactId = ?"
-					+ " LIMIT ?";
+			String sql = "SELECT messageId, parentId, authorId, authorName,"
+					+ " authorKey, contentType, timestamp, read"
+					+ " FROM messages"
+					+ " WHERE groupId = ?";
 			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
-			ps.setInt(2, maxMessages);
+			ps.setBytes(1, g.getBytes());
 			rs = ps.executeQuery();
-			List<MessageId> ids = new ArrayList<MessageId>();
-			while(rs.next()) ids.add(new MessageId(rs.getBytes(1)));
-			rs.close();
-			ps.close();
+			List<MessageHeader> headers = new ArrayList<MessageHeader>();
+			while(rs.next()) {
+				MessageId id = new MessageId(rs.getBytes(1));
+				byte[] b = rs.getBytes(2);
+				MessageId parent = b == null ? null : new MessageId(b);
+				Author author;
+				b = rs.getBytes(3);
+				if(b == null) {
+					author = null;
+				} else {
+					AuthorId authorId = new AuthorId(b);
+					String authorName = rs.getString(4);
+					byte[] authorKey = rs.getBytes(5);
+					author = new Author(authorId, authorName, authorKey);
+				}
+				String contentType = rs.getString(6);
+				long timestamp = rs.getLong(7);
+				boolean read = rs.getBoolean(8);
+				headers.add(new MessageHeader(id, parent, g, author,
+						contentType, timestamp, read));
+			}
+			rs.close();
+			ps.close();
+			return Collections.unmodifiableList(headers);
+		} catch(SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
+	public Collection<MessageId> getMessagesToAck(Connection txn, ContactId c,
+			int maxMessages) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT messageId FROM messagesToAck"
+					+ " WHERE contactId = ?"
+					+ " LIMIT ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setInt(2, maxMessages);
+			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<MessageId>();
+			while(rs.next()) ids.add(new MessageId(rs.getBytes(1)));
+			rs.close();
+			ps.close();
 			return Collections.unmodifiableList(ids);
 		} catch(SQLException e) {
 			tryToClose(rs);
@@ -1648,72 +1748,26 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public Collection<PrivateMessageHeader> getPrivateMessageHeaders(
-			Connection txn, ContactId c) throws DbException {
+	public MessageId getParent(Connection txn, MessageId m) throws DbException {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			// Get the incoming message headers
-			String sql = "SELECT m.messageId, parentId, contentType,"
-					+ " timestamp, read, c.authorId, name, publicKey"
-					+ " FROM messages AS m"
-					+ " JOIN contacts AS c"
-					+ " ON m.contactId = c.contactId"
-					+ " WHERE m.contactId = ?"
-					+ " AND groupId IS NULL"
-					+ " AND incoming = TRUE";
-			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
-			rs = ps.executeQuery();
-			List<PrivateMessageHeader> headers =
-					new ArrayList<PrivateMessageHeader>();
-			while(rs.next()) {
-				MessageId id = new MessageId(rs.getBytes(1));
-				byte[] b = rs.getBytes(2);
-				MessageId parent = b == null ? null : new MessageId(b);
-				String contentType = rs.getString(3);
-				long timestamp = rs.getLong(4);
-				boolean read = rs.getBoolean(5);
-				AuthorId authorId = new AuthorId(rs.getBytes(6));
-				String authorName = rs.getString(7);
-				byte[] authorKey = rs.getBytes(8);
-				Author author = new Author(authorId, authorName, authorKey);
-				headers.add(new PrivateMessageHeader(id, parent, author,
-						contentType, timestamp, read, c, true));
-			}
-			rs.close();
-			ps.close();
-			// Get the outgoing message headers
-			sql = "SELECT m.messageId, parentId, contentType, timestamp, read,"
-					+ " a.authorId, a.name, a.publicKey"
-					+ " FROM messages AS m"
-					+ " JOIN contacts AS c"
-					+ " ON m.contactId = c.contactId"
-					+ " JOIN localAuthors AS a"
-					+ " ON c.localAuthorId = a.authorId"
-					+ " WHERE m.contactId = ?"
-					+ " AND groupId IS NULL"
-					+ " AND incoming = FALSE";
+			String sql = "SELECT m1.parentId FROM messages AS m1"
+					+ " JOIN messages AS m2"
+					+ " ON m1.parentId = m2.messageId"
+					+ " AND m1.groupId = m2.groupId"
+					+ " WHERE m1.messageId = ?";
 			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
+			ps.setBytes(1, m.getBytes());
 			rs = ps.executeQuery();
-			while(rs.next()) {
-				MessageId id = new MessageId(rs.getBytes(1));
-				byte[] b = rs.getBytes(2);
-				MessageId parent = b == null ? null : new MessageId(b);
-				String contentType = rs.getString(3);
-				long timestamp = rs.getLong(4);
-				boolean read = rs.getBoolean(5);
-				AuthorId authorId = new AuthorId(rs.getBytes(6));
-				String authorName = rs.getString(7);
-				byte[] authorKey = rs.getBytes(8);
-				Author author = new Author(authorId, authorName, authorKey);
-				headers.add(new PrivateMessageHeader(id, parent, author,
-						contentType, timestamp, read, c, false));
+			MessageId parent = null;
+			if(rs.next()) {
+				parent = new MessageId(rs.getBytes(1));
+				if(rs.next()) throw new DbStateException();
 			}
 			rs.close();
 			ps.close();
-			return Collections.unmodifiableList(headers);
+			return parent;
 		} catch(SQLException e) {
 			tryToClose(rs);
 			tryToClose(ps);
@@ -1751,29 +1805,7 @@ abstract class JdbcDatabase implements Database<Connection> {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			// Do we have a sendable private message with the given ID?
 			String sql = "SELECT length, raw FROM messages AS m"
-					+ " JOIN statuses AS s"
-					+ " ON m.messageId = s.messageId"
-					+ " WHERE m.messageId = ? AND m.contactId = ?"
-					+ " AND seen = FALSE AND expiry < ?";
-			ps = txn.prepareStatement(sql);
-			ps.setBytes(1, m.getBytes());
-			ps.setInt(2, c.getInt());
-			ps.setLong(3, now);
-			rs = ps.executeQuery();
-			byte[] raw = null;
-			if(rs.next()) {
-				int length = rs.getInt(1);
-				raw = rs.getBlob(2).getBytes(1, length);
-				if(raw.length != length) throw new DbStateException();
-			}
-			if(rs.next()) throw new DbStateException();
-			rs.close();
-			ps.close();
-			if(raw != null) return raw;
-			// Do we have a sendable group message with the given ID?
-			sql = "SELECT length, raw FROM messages AS m"
 					+ " JOIN contactGroups AS cg"
 					+ " ON m.groupId = cg.groupId"
 					+ " JOIN groupVisibilities AS gv"
@@ -1793,6 +1825,7 @@ abstract class JdbcDatabase implements Database<Connection> {
 			ps.setInt(2, c.getInt());
 			ps.setLong(3, now);
 			rs = ps.executeQuery();
+			byte[] raw = null;
 			if(rs.next()) {
 				int length = rs.getInt(1);
 				raw = rs.getBlob(2).getBytes(1, length);
@@ -1989,29 +2022,7 @@ abstract class JdbcDatabase implements Database<Connection> {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			// Do we have any sendable private messages?
 			String sql = "SELECT length, m.messageId FROM messages AS m"
-					+ " JOIN statuses AS s"
-					+ " ON m.messageId = s.messageId"
-					+ " WHERE m.contactId = ? AND seen = FALSE AND expiry < ?"
-					+ " ORDER BY timestamp DESC";
-			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
-			ps.setLong(2, now);
-			rs = ps.executeQuery();
-			List<MessageId> ids = new ArrayList<MessageId>();
-			int total = 0;
-			while(rs.next()) {
-				int length = rs.getInt(1);
-				if(total + length > maxLength) break;
-				ids.add(new MessageId(rs.getBytes(2)));
-				total += length;
-			}
-			rs.close();
-			ps.close();
-			if(total == maxLength) return Collections.unmodifiableList(ids);
-			// Do we have any sendable group messages?
-			sql = "SELECT length, m.messageId FROM messages AS m"
 					+ " JOIN contactGroups AS cg"
 					+ " ON m.groupId = cg.groupId"
 					+ " JOIN groupVisibilities AS gv"
@@ -2030,6 +2041,8 @@ abstract class JdbcDatabase implements Database<Connection> {
 			ps.setInt(1, c.getInt());
 			ps.setLong(2, now);
 			rs = ps.executeQuery();
+			List<MessageId> ids = new ArrayList<MessageId>();
+			int total = 0;
 			while(rs.next()) {
 				int length = rs.getInt(1);
 				if(total + length > maxLength) break;
@@ -2046,58 +2059,6 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public Collection<Group> getSubscriptions(Connection txn)
-			throws DbException {
-		PreparedStatement ps = null;
-		ResultSet rs = null;
-		try {
-			String sql = "SELECT groupId, name, salt FROM groups";
-			ps = txn.prepareStatement(sql);
-			rs = ps.executeQuery();
-			List<Group> subs = new ArrayList<Group>();
-			while(rs.next()) {
-				GroupId groupId = new GroupId(rs.getBytes(1));
-				String name = rs.getString(2);
-				byte[] salt = rs.getBytes(3);
-				subs.add(new Group(groupId, name, salt));
-			}
-			rs.close();
-			ps.close();
-			return Collections.unmodifiableList(subs);
-		} catch(SQLException e) {
-			tryToClose(rs);
-			tryToClose(ps);
-			throw new DbException(e);
-		}
-	}
-
-	public Collection<Group> getSubscriptions(Connection txn, ContactId c)
-			throws DbException {
-		PreparedStatement ps = null;
-		ResultSet rs = null;
-		try {
-			String sql = "SELECT groupId, name, salt FROM contactGroups"
-					+ " WHERE contactId = ?";
-			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
-			rs = ps.executeQuery();
-			List<Group> subs = new ArrayList<Group>();
-			while(rs.next()) {
-				GroupId groupId = new GroupId(rs.getBytes(1));
-				String name = rs.getString(2);
-				byte[] salt = rs.getBytes(3);
-				subs.add(new Group(groupId, name, salt));
-			}
-			rs.close();
-			ps.close();
-			return Collections.unmodifiableList(subs);
-		} catch(SQLException e) {
-			tryToClose(rs);
-			tryToClose(ps);
-			throw new DbException(e);
-		}
-	}
-
 	public SubscriptionAck getSubscriptionAck(Connection txn, ContactId c)
 			throws DbException {
 		PreparedStatement ps = null;
@@ -2138,7 +2099,7 @@ abstract class JdbcDatabase implements Database<Connection> {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT g.groupId, name, salt,"
+			String sql = "SELECT g.groupId, name, salt, private,"
 					+ " localVersion, txCount"
 					+ " FROM groups AS g"
 					+ " JOIN groupVisibilities AS vis"
@@ -2152,20 +2113,21 @@ abstract class JdbcDatabase implements Database<Connection> {
 			ps.setInt(1, c.getInt());
 			ps.setLong(2, now);
 			rs = ps.executeQuery();
-			List<Group> subs = new ArrayList<Group>();
+			List<Group> groups = new ArrayList<Group>();
 			long version = 0;
 			int txCount = 0;
 			while(rs.next()) {
-				GroupId groupId = new GroupId(rs.getBytes(1));
+				GroupId id = new GroupId(rs.getBytes(1));
 				String name = rs.getString(2);
 				byte[] salt = rs.getBytes(3);
-				subs.add(new Group(groupId, name, salt));
-				version = rs.getLong(4);
-				txCount = rs.getInt(5);
+				boolean isPrivate = rs.getBoolean(4);
+				groups.add(new Group(id, name, salt, isPrivate));
+				version = rs.getLong(5);
+				txCount = rs.getInt(6);
 			}
 			rs.close();
 			ps.close();
-			if(subs.isEmpty()) return null;
+			if(groups.isEmpty()) return null;
 			sql = "UPDATE groupVersions"
 					+ " SET expiry = ?, txCount = txCount + 1"
 					+ " WHERE contactId = ?";
@@ -2175,8 +2137,8 @@ abstract class JdbcDatabase implements Database<Connection> {
 			int affected = ps.executeUpdate();
 			if(affected != 1) throw new DbStateException();
 			ps.close();
-			subs = Collections.unmodifiableList(subs);
-			return new SubscriptionUpdate(subs, version);
+			groups = Collections.unmodifiableList(groups);
+			return new SubscriptionUpdate(groups, version);
 		} catch(SQLException e) {
 			tryToClose(ps);
 			tryToClose(rs);
@@ -2347,7 +2309,6 @@ abstract class JdbcDatabase implements Database<Connection> {
 			String sql = "SELECT groupId, COUNT(*)"
 					+ " FROM messages AS m"
 					+ " WHERE read = FALSE"
-					+ " AND groupId IS NOT NULL"
 					+ " GROUP BY groupId";
 			ps = txn.prepareStatement(sql);
 			rs = ps.executeQuery();
@@ -2388,13 +2349,17 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public Collection<GroupId> getVisibleSubscriptions(Connection txn,
+	public Collection<GroupId> getVisiblePrivateGroups(Connection txn,
 			ContactId c) throws DbException {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT groupId FROM groupVisibilities"
-					+ " WHERE contactId = ?";
+			String sql = "SELECT g.groupId"
+					+ " FROM groups AS g"
+					+ " JOIN groupVisibilities AS gv"
+					+ " ON g.groupId = gv.groupId"
+					+ " WHERE contactId = ?"
+					+ " AND private = TRUE";
 			ps = txn.prepareStatement(sql);
 			ps.setInt(1, c.getInt());
 			rs = ps.executeQuery();
@@ -2410,59 +2375,6 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public boolean hasSendableMessages(Connection txn, ContactId c)
-			throws DbException {
-		long now = clock.currentTimeMillis();
-		PreparedStatement ps = null;
-		ResultSet rs = null;
-		try {
-			// Do we have any sendable private messages?
-			String sql = "SELECT m.messageId FROM messages AS m"
-					+ " JOIN statuses AS s"
-					+ " ON m.messageId = s.messageId"
-					+ " WHERE m.contactId = ? AND seen = FALSE AND expiry < ?"
-					+ " LIMIT 1";
-			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
-			ps.setLong(2, now);
-			rs = ps.executeQuery();
-			boolean found = rs.next();
-			if(rs.next()) throw new DbStateException();
-			rs.close();
-			ps.close();
-			if(found) return true;
-			// Do we have any sendable group messages?
-			sql = "SELECT m.messageId FROM messages AS m"
-					+ " JOIN contactGroups AS cg"
-					+ " ON m.groupId = cg.groupId"
-					+ " JOIN groupVisibilities AS gv"
-					+ " ON m.groupId = gv.groupId"
-					+ " AND cg.contactId = gv.contactId"
-					+ " JOIN retentionVersions AS rv"
-					+ " ON cg.contactId = rv.contactId"
-					+ " JOIN statuses AS s"
-					+ " ON m.messageId = s.messageId"
-					+ " AND cg.contactId = s.contactId"
-					+ " WHERE cg.contactId = ?"
-					+ " AND timestamp >= retention"
-					+ " AND seen = FALSE AND s.expiry < ?"
-					+ " LIMIT 1";
-			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
-			ps.setLong(2, now);
-			rs = ps.executeQuery();
-			found = rs.next();
-			if(rs.next()) throw new DbStateException();
-			rs.close();
-			ps.close();
-			return found;
-		} catch(SQLException e) {
-			tryToClose(rs);
-			tryToClose(ps);
-			throw new DbException(e);
-		}
-	}
-
 	public long incrementConnectionCounter(Connection txn, ContactId c,
 			TransportId t, long period) throws DbException {
 		PreparedStatement ps = null;
@@ -2516,25 +2428,25 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public void removeOutstandingMessages(Connection txn, ContactId c,
-			Collection<MessageId> acked) throws DbException {
+	public void mergeConfig(Connection txn, TransportId t, TransportConfig c)
+			throws DbException {
+		// Merge the new configuration with the existing one
+		mergeStringMap(txn, t, c, "transportConfigs");
+	}
+
+	public void mergeLocalProperties(Connection txn, TransportId t,
+			TransportProperties p) throws DbException {
+		// Merge the new properties with the existing ones
+		mergeStringMap(txn, t, p, "transportProperties");
+		// Bump the transport version
 		PreparedStatement ps = null;
 		try {
-			// Set the status of each message to seen = true
-			String sql = "UPDATE statuses SET seen = TRUE"
-					+ " WHERE messageId = ? AND contactId = ?";
+			String sql = "UPDATE transportVersions"
+					+ " SET localVersion = localVersion + 1, expiry = 0"
+					+ " WHERE transportId = ?";
 			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
-			for(MessageId m : acked) {
-				ps.setBytes(2, m.getBytes());
-				ps.addBatch();
-			}
-			int[] batchAffected = ps.executeBatch();
-			if(batchAffected.length != acked.size())
-				throw new DbStateException();
-			for(int i = 0; i < batchAffected.length; i++) {
-				if(batchAffected[i] > 1) throw new DbStateException();
-			}
+			ps.setBytes(1, t.getBytes());
+			ps.executeUpdate();
 			ps.close();
 		} catch(SQLException e) {
 			tryToClose(ps);
@@ -2542,21 +2454,42 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public void removeMessagesToAck(Connection txn, ContactId c,
-			Collection<MessageId> acked) throws DbException {
+	private void mergeStringMap(Connection txn, TransportId t,
+			Map<String, String> m, String tableName) throws DbException {
 		PreparedStatement ps = null;
 		try {
-			String sql = "DELETE FROM messagesToAck"
-					+ " WHERE contactId = ? AND messageId = ?";
+			// Update any properties that already exist
+			String sql = "UPDATE " + tableName + " SET value = ?"
+					+ " WHERE transportId = ? AND key = ?";
 			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
-			for(MessageId m : acked) {
-				ps.setBytes(2, m.getBytes());
+			ps.setBytes(2, t.getBytes());
+			for(Entry<String, String> e : m.entrySet()) {
+				ps.setString(1, e.getValue());
+				ps.setString(3, e.getKey());
 				ps.addBatch();
 			}
 			int[] batchAffected = ps.executeBatch();
-			if(batchAffected.length != acked.size())
-				throw new DbStateException();
+			if(batchAffected.length != m.size()) throw new DbStateException();
+			for(int i = 0; i < batchAffected.length; i++) {
+				if(batchAffected[i] > 1) throw new DbStateException();
+			}
+			// Insert any properties that don't already exist
+			sql = "INSERT INTO " + tableName + " (transportId, key, value)"
+					+ " VALUES (?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, t.getBytes());
+			int updateIndex = 0, inserted = 0;
+			for(Entry<String, String> e : m.entrySet()) {
+				if(batchAffected[updateIndex] == 0) {
+					ps.setString(2, e.getKey());
+					ps.setString(3, e.getValue());
+					ps.addBatch();
+					inserted++;
+				}
+				updateIndex++;
+			}
+			batchAffected = ps.executeBatch();
+			if(batchAffected.length != inserted) throw new DbStateException();
 			for(int i = 0; i < batchAffected.length; i++) {
 				if(batchAffected[i] != 1) throw new DbStateException();
 			}
@@ -2583,6 +2516,51 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
+	public void removeGroup(Connection txn, GroupId g) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			// Find out which contacts are affected
+			String sql = "SELECT contactId FROM groupVisibilities"
+					+ " WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			rs = ps.executeQuery();
+			Collection<Integer> visible = new ArrayList<Integer>();
+			while(rs.next()) visible.add(rs.getInt(1));
+			rs.close();
+			ps.close();
+			// Delete the group
+			sql = "DELETE FROM groups WHERE groupId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setBytes(1, g.getBytes());
+			int affected = ps.executeUpdate();
+			if(affected != 1) throw new DbStateException();
+			ps.close();
+			if(visible.isEmpty()) return;
+			// Bump the subscription versions for the affected contacts
+			sql = "UPDATE groupVersions"
+					+ " SET localVersion = localVersion + 1, expiry = 0"
+					+ " WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			for(Integer c : visible) {
+				ps.setInt(1, c);
+				ps.addBatch();
+			}
+			int[] batchAffected = ps.executeBatch();
+			if(batchAffected.length != visible.size())
+				throw new DbStateException();
+			for(int i = 0; i < batchAffected.length; i++) {
+				if(batchAffected[i] != 1) throw new DbStateException();
+			}
+			ps.close();
+		} catch(SQLException e) {
+			tryToClose(ps);
+			tryToClose(rs);
+			throw new DbException(e);
+		}
+	}
+
 	public void removeLocalAuthor(Connection txn, AuthorId a)
 			throws DbException {
 		PreparedStatement ps = null;
@@ -2614,40 +2592,20 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public void removeSubscription(Connection txn, GroupId g)
-			throws DbException {
+	public void removeMessagesToAck(Connection txn, ContactId c,
+			Collection<MessageId> acked) throws DbException {
 		PreparedStatement ps = null;
-		ResultSet rs = null;
 		try {
-			// Find out which contacts are affected
-			String sql = "SELECT contactId FROM groupVisibilities"
-					+ " WHERE groupId = ?";
-			ps = txn.prepareStatement(sql);
-			ps.setBytes(1, g.getBytes());
-			rs = ps.executeQuery();
-			Collection<Integer> visible = new ArrayList<Integer>();
-			while(rs.next()) visible.add(rs.getInt(1));
-			rs.close();
-			ps.close();
-			// Delete the group
-			sql = "DELETE FROM groups WHERE groupId = ?";
-			ps = txn.prepareStatement(sql);
-			ps.setBytes(1, g.getBytes());
-			int affected = ps.executeUpdate();
-			if(affected != 1) throw new DbStateException();
-			ps.close();
-			if(visible.isEmpty()) return;
-			// Bump the subscription version for the affected contacts
-			sql = "UPDATE groupVersions"
-					+ " SET localVersion = localVersion + 1, expiry = 0"
-					+ " WHERE contactId = ?";
+			String sql = "DELETE FROM messagesToAck"
+					+ " WHERE contactId = ? AND messageId = ?";
 			ps = txn.prepareStatement(sql);
-			for(Integer c : visible) {
-				ps.setInt(1, c);
+			ps.setInt(1, c.getInt());
+			for(MessageId m : acked) {
+				ps.setBytes(2, m.getBytes());
 				ps.addBatch();
 			}
 			int[] batchAffected = ps.executeBatch();
-			if(batchAffected.length != visible.size())
+			if(batchAffected.length != acked.size())
 				throw new DbStateException();
 			for(int i = 0; i < batchAffected.length; i++) {
 				if(batchAffected[i] != 1) throw new DbStateException();
@@ -2655,7 +2613,6 @@ abstract class JdbcDatabase implements Database<Connection> {
 			ps.close();
 		} catch(SQLException e) {
 			tryToClose(ps);
-			tryToClose(rs);
 			throw new DbException(e);
 		}
 	}
@@ -2703,25 +2660,20 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public void mergeConfig(Connection txn, TransportId t, TransportConfig c)
-			throws DbException {
-		// Merge the new configuration with the existing one
-		mergeStringMap(txn, t, c, "transportConfigs");
-	}
-
-	public void mergeLocalProperties(Connection txn, TransportId t,
-			TransportProperties p) throws DbException {
-		// Merge the new properties with the existing ones
-		mergeStringMap(txn, t, p, "transportProperties");
-		// Bump the transport version
+	public void setConnectionWindow(Connection txn, ContactId c, TransportId t,
+			long period, long centre, byte[] bitmap) throws DbException {
 		PreparedStatement ps = null;
 		try {
-			String sql = "UPDATE transportVersions"
-					+ " SET localVersion = localVersion + 1, expiry = 0"
-					+ " WHERE transportId = ?";
+			String sql = "UPDATE secrets SET centre = ?, bitmap = ?"
+					+ " WHERE contactId = ? AND transportId = ? AND period = ?";
 			ps = txn.prepareStatement(sql);
-			ps.setBytes(1, t.getBytes());
-			ps.executeUpdate();
+			ps.setLong(1, centre);
+			ps.setBytes(2, bitmap);
+			ps.setInt(3, c.getInt());
+			ps.setBytes(4, t.getBytes());
+			ps.setLong(5, period);
+			int affected = ps.executeUpdate();
+			if(affected > 1) throw new DbStateException();
 			ps.close();
 		} catch(SQLException e) {
 			tryToClose(ps);
@@ -2729,66 +2681,69 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	private void mergeStringMap(Connection txn, TransportId t,
-			Map<String, String> m, String tableName) throws DbException {
+	public boolean setGroups(Connection txn, ContactId c,
+			Collection<Group> groups, long version) throws DbException {
 		PreparedStatement ps = null;
+		ResultSet rs = null;
 		try {
-			// Update any properties that already exist
-			String sql = "UPDATE " + tableName + " SET value = ?"
-					+ " WHERE transportId = ? AND key = ?";
+			// Mark the update as needing to be acked
+			String sql = "UPDATE groupVersions"
+					+ " SET remoteVersion = ?, remoteAcked = FALSE"
+					+ " WHERE contactId = ? AND remoteVersion < ?";
 			ps = txn.prepareStatement(sql);
-			ps.setBytes(2, t.getBytes());
-			for(Entry<String, String> e : m.entrySet()) {
-				ps.setString(1, e.getValue());
-				ps.setString(3, e.getKey());
+			ps.setLong(1, version);
+			ps.setInt(2, c.getInt());
+			ps.setLong(3, version);
+			int affected = ps.executeUpdate();
+			if(affected > 1) throw new DbStateException();
+			ps.close();
+			// Return false if the update is obsolete
+			if(affected == 0) return false;
+			// Delete the existing subscriptions, if any
+			sql = "DELETE FROM contactGroups WHERE contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.executeUpdate();
+			// Store the new subscriptions, if any
+			if(groups.isEmpty()) return true;
+			sql = "INSERT INTO contactGroups"
+					+ " (contactId, groupId, name, salt, private)"
+					+ " VALUES (?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			for(Group g : groups) {
+				ps.setBytes(2, g.getId().getBytes());
+				ps.setString(3, g.getName());
+				ps.setBytes(4, g.getSalt());
+				ps.setBoolean(5, g.isPrivate());
 				ps.addBatch();
 			}
 			int[] batchAffected = ps.executeBatch();
-			if(batchAffected.length != m.size()) throw new DbStateException();
-			for(int i = 0; i < batchAffected.length; i++) {
-				if(batchAffected[i] > 1) throw new DbStateException();
-			}
-			// Insert any properties that don't already exist
-			sql = "INSERT INTO " + tableName + " (transportId, key, value)"
-					+ " VALUES (?, ?, ?)";
-			ps = txn.prepareStatement(sql);
-			ps.setBytes(1, t.getBytes());
-			int updateIndex = 0, inserted = 0;
-			for(Entry<String, String> e : m.entrySet()) {
-				if(batchAffected[updateIndex] == 0) {
-					ps.setString(2, e.getKey());
-					ps.setString(3, e.getValue());
-					ps.addBatch();
-					inserted++;
-				}
-				updateIndex++;
-			}
-			batchAffected = ps.executeBatch();
-			if(batchAffected.length != inserted) throw new DbStateException();
+			if(batchAffected.length != groups.size())
+				throw new DbStateException();
 			for(int i = 0; i < batchAffected.length; i++) {
 				if(batchAffected[i] != 1) throw new DbStateException();
 			}
 			ps.close();
+			return true;
 		} catch(SQLException e) {
 			tryToClose(ps);
+			tryToClose(rs);
 			throw new DbException(e);
 		}
 	}
 
-	public void setConnectionWindow(Connection txn, ContactId c, TransportId t,
-			long period, long centre, byte[] bitmap) throws DbException {
+	public void setLastConnected(Connection txn, ContactId c, long now)
+			throws DbException {
 		PreparedStatement ps = null;
 		try {
-			String sql = "UPDATE secrets SET centre = ?, bitmap = ?"
-					+ " WHERE contactId = ? AND transportId = ? AND period = ?";
+			String sql = "UPDATE connectionTimes SET lastConnected = ?"
+					+ " WHERE contactId = ?";
 			ps = txn.prepareStatement(sql);
-			ps.setLong(1, centre);
-			ps.setBytes(2, bitmap);
-			ps.setInt(3, c.getInt());
-			ps.setBytes(4, t.getBytes());
-			ps.setLong(5, period);
+			ps.setLong(1, now);
+			ps.setInt(2, c.getInt());
 			int affected = ps.executeUpdate();
-			if(affected > 1) throw new DbStateException();
+			if(affected < 1) throw new DbStateException();
 			ps.close();
 		} catch(SQLException e) {
 			tryToClose(ps);
@@ -2796,17 +2751,43 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public void setLastConnected(Connection txn, ContactId c, long now)
+	public void setInboxGroup(Connection txn, ContactId c, Group g)
 			throws DbException {
+		if(!g.isPrivate()) throw new IllegalArgumentException();
 		PreparedStatement ps = null;
 		try {
-			String sql = "UPDATE connectionTimes SET lastConnected = ?"
+			// Unset any existing inbox group for the contact
+			String sql = "UPDATE groupVisibilities"
+					+ " SET inbox = FALSE"
 					+ " WHERE contactId = ?";
 			ps = txn.prepareStatement(sql);
-			ps.setLong(1, now);
-			ps.setInt(2, c.getInt());
+			ps.setInt(1, c.getInt());
+			ps.executeUpdate();
 			int affected = ps.executeUpdate();
-			if(affected < 1) throw new DbStateException();
+			if(affected > 1) throw new DbStateException();
+			ps.close();
+			// Make the group visible to the contact and set it as the inbox
+			sql = "INSERT INTO groupVisibilities"
+					+ " (contactId, groupId, inbox)"
+					+ " VALUES (?, ?, TRUE)";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, g.getId().getBytes());
+			affected = ps.executeUpdate();
+			if(affected != 1) throw new DbStateException();
+			ps.close();
+			// Add the group to the contact's subscriptions
+			sql = "INSERT INTO contactGroups"
+					+ " (contactId, groupId, name, salt, private)"
+					+ " VALUES (?, ?, ?, ?, ?)";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			ps.setBytes(2, g.getId().getBytes());
+			ps.setString(3, g.getName());
+			ps.setBytes(4, g.getSalt());
+			ps.setBoolean(5, g.isPrivate());
+			affected = ps.executeUpdate();
+			if(affected != 1) throw new DbStateException();
 			ps.close();
 		} catch(SQLException e) {
 			tryToClose(ps);
@@ -3050,57 +3031,6 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
-	public boolean setSubscriptions(Connection txn, ContactId c,
-			Collection<Group> subs, long version) throws DbException {
-		PreparedStatement ps = null;
-		ResultSet rs = null;
-		try {
-			// Mark the update as needing to be acked
-			String sql = "UPDATE groupVersions"
-					+ " SET remoteVersion = ?, remoteAcked = FALSE"
-					+ " WHERE contactId = ? AND remoteVersion < ?";
-			ps = txn.prepareStatement(sql);
-			ps.setLong(1, version);
-			ps.setInt(2, c.getInt());
-			ps.setLong(3, version);
-			int affected = ps.executeUpdate();
-			if(affected > 1) throw new DbStateException();
-			ps.close();
-			// Return false if the update is obsolete
-			if(affected == 0) return false;
-			// Delete the existing subscriptions, if any
-			sql = "DELETE FROM contactGroups WHERE contactId = ?";
-			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
-			ps.executeUpdate();
-			// Store the new subscriptions, if any
-			if(subs.isEmpty()) return true;
-			sql = "INSERT INTO contactGroups"
-					+ " (contactId, groupId, name, salt)"
-					+ " VALUES (?, ?, ?, ?)";
-			ps = txn.prepareStatement(sql);
-			ps.setInt(1, c.getInt());
-			for(Group g : subs) {
-				ps.setBytes(2, g.getId().getBytes());
-				ps.setString(3, g.getName());
-				ps.setBytes(4, g.getSalt());
-				ps.addBatch();
-			}
-			int[] batchAffected = ps.executeBatch();
-			if(batchAffected.length != subs.size())
-				throw new DbStateException();
-			for(int i = 0; i < batchAffected.length; i++) {
-				if(batchAffected[i] != 1) throw new DbStateException();
-			}
-			ps.close();
-			return true;
-		} catch(SQLException e) {
-			tryToClose(ps);
-			tryToClose(rs);
-			throw new DbException(e);
-		}
-	}
-
 	public void setSubscriptionUpdateAcked(Connection txn, ContactId c,
 			long version) throws DbException {
 		PreparedStatement ps = null;
diff --git a/briar-core/src/net/sf/briar/invitation/AliceConnector.java b/briar-core/src/net/sf/briar/invitation/AliceConnector.java
index 84cab4286e..3fe1c54917 100644
--- a/briar-core/src/net/sf/briar/invitation/AliceConnector.java
+++ b/briar-core/src/net/sf/briar/invitation/AliceConnector.java
@@ -21,6 +21,7 @@ import net.sf.briar.api.crypto.KeyManager;
 import net.sf.briar.api.crypto.PseudoRandom;
 import net.sf.briar.api.db.DatabaseComponent;
 import net.sf.briar.api.db.DbException;
+import net.sf.briar.api.messaging.GroupFactory;
 import net.sf.briar.api.plugins.duplex.DuplexPlugin;
 import net.sf.briar.api.plugins.duplex.DuplexTransportConnection;
 import net.sf.briar.api.serial.Reader;
@@ -43,15 +44,16 @@ class AliceConnector extends Connector {
 			ReaderFactory readerFactory, WriterFactory writerFactory,
 			ConnectionReaderFactory connectionReaderFactory,
 			ConnectionWriterFactory connectionWriterFactory,
-			AuthorFactory authorFactory, KeyManager keyManager,
-			ConnectionDispatcher connectionDispatcher, Clock clock,
-			ConnectorGroup group, DuplexPlugin plugin, LocalAuthor localAuthor,
+			AuthorFactory authorFactory, GroupFactory groupFactory,
+			KeyManager keyManager, ConnectionDispatcher connectionDispatcher,
+			Clock clock, ConnectorGroup group, DuplexPlugin plugin,
+			LocalAuthor localAuthor,
 			Map<TransportId, TransportProperties> localProps,
 			PseudoRandom random) {
 		super(crypto, db, readerFactory, writerFactory, connectionReaderFactory,
-				connectionWriterFactory, authorFactory, keyManager,
-				connectionDispatcher, clock, group, plugin, localAuthor,
-				localProps, random);
+				connectionWriterFactory, authorFactory, groupFactory,
+				keyManager, connectionDispatcher, clock, group, plugin,
+				localAuthor, localProps, random);
 	}
 
 	@Override
diff --git a/briar-core/src/net/sf/briar/invitation/BobConnector.java b/briar-core/src/net/sf/briar/invitation/BobConnector.java
index ae7c02144c..4d05f92f7c 100644
--- a/briar-core/src/net/sf/briar/invitation/BobConnector.java
+++ b/briar-core/src/net/sf/briar/invitation/BobConnector.java
@@ -21,6 +21,7 @@ import net.sf.briar.api.crypto.KeyManager;
 import net.sf.briar.api.crypto.PseudoRandom;
 import net.sf.briar.api.db.DatabaseComponent;
 import net.sf.briar.api.db.DbException;
+import net.sf.briar.api.messaging.GroupFactory;
 import net.sf.briar.api.plugins.duplex.DuplexPlugin;
 import net.sf.briar.api.plugins.duplex.DuplexTransportConnection;
 import net.sf.briar.api.serial.Reader;
@@ -43,15 +44,16 @@ class BobConnector extends Connector {
 			ReaderFactory readerFactory, WriterFactory writerFactory,
 			ConnectionReaderFactory connectionReaderFactory,
 			ConnectionWriterFactory connectionWriterFactory,
-			AuthorFactory authorFactory, KeyManager keyManager,
-			ConnectionDispatcher connectionDispatcher, Clock clock,
-			ConnectorGroup group, DuplexPlugin plugin, LocalAuthor localAuthor,
+			AuthorFactory authorFactory, GroupFactory groupFactory,
+			KeyManager keyManager, ConnectionDispatcher connectionDispatcher,
+			Clock clock, ConnectorGroup group, DuplexPlugin plugin,
+			LocalAuthor localAuthor,
 			Map<TransportId, TransportProperties> localProps,
 			PseudoRandom random) {
 		super(crypto, db, readerFactory, writerFactory, connectionReaderFactory,
-				connectionWriterFactory, authorFactory, keyManager,
-				connectionDispatcher, clock, group, plugin, localAuthor,
-				localProps, random);
+				connectionWriterFactory, authorFactory, groupFactory,
+				keyManager, connectionDispatcher, clock, group, plugin,
+				localAuthor, localProps, random);
 	}
 
 	@Override
diff --git a/briar-core/src/net/sf/briar/invitation/Connector.java b/briar-core/src/net/sf/briar/invitation/Connector.java
index 5cceedb6cb..5d22468788 100644
--- a/briar-core/src/net/sf/briar/invitation/Connector.java
+++ b/briar-core/src/net/sf/briar/invitation/Connector.java
@@ -41,6 +41,8 @@ import net.sf.briar.api.crypto.Signature;
 import net.sf.briar.api.db.DatabaseComponent;
 import net.sf.briar.api.db.DbException;
 import net.sf.briar.api.db.NoSuchTransportException;
+import net.sf.briar.api.messaging.Group;
+import net.sf.briar.api.messaging.GroupFactory;
 import net.sf.briar.api.plugins.duplex.DuplexPlugin;
 import net.sf.briar.api.plugins.duplex.DuplexTransportConnection;
 import net.sf.briar.api.serial.Reader;
@@ -64,6 +66,7 @@ abstract class Connector extends Thread {
 	protected final ConnectionReaderFactory connectionReaderFactory;
 	protected final ConnectionWriterFactory connectionWriterFactory;
 	protected final AuthorFactory authorFactory;
+	protected final GroupFactory groupFactory;
 	protected final KeyManager keyManager;
 	protected final ConnectionDispatcher connectionDispatcher;
 	protected final Clock clock;
@@ -84,9 +87,10 @@ abstract class Connector extends Thread {
 			ReaderFactory readerFactory, WriterFactory writerFactory,
 			ConnectionReaderFactory connectionReaderFactory,
 			ConnectionWriterFactory connectionWriterFactory,
-			AuthorFactory authorFactory, KeyManager keyManager,
-			ConnectionDispatcher connectionDispatcher, Clock clock,
-			ConnectorGroup group, DuplexPlugin plugin, LocalAuthor localAuthor,
+			AuthorFactory authorFactory, GroupFactory groupFactory,
+			KeyManager keyManager, ConnectionDispatcher connectionDispatcher,
+			Clock clock, ConnectorGroup group, DuplexPlugin plugin,
+			LocalAuthor localAuthor,
 			Map<TransportId, TransportProperties> localProps,
 			PseudoRandom random) {
 		super("Connector");
@@ -97,6 +101,7 @@ abstract class Connector extends Thread {
 		this.connectionReaderFactory = connectionReaderFactory;
 		this.connectionWriterFactory = connectionWriterFactory;
 		this.authorFactory = authorFactory;
+		this.groupFactory = groupFactory;
 		this.keyManager = keyManager;
 		this.connectionDispatcher = connectionDispatcher;
 		this.clock = clock;
@@ -267,6 +272,11 @@ abstract class Connector extends Thread {
 			long epoch, boolean alice) throws DbException {
 		// Add the contact to the database
 		contactId = db.addContact(remoteAuthor, localAuthor.getId());
+		// Create and store the inbox group
+		byte[] salt = crypto.deriveGroupSalt(secret);
+		Group inbox = groupFactory.createGroup("Inbox", salt, true);
+		db.addGroup(inbox);
+		db.setInboxGroup(contactId, inbox);
 		// Store the remote transport properties
 		db.setRemoteProperties(contactId, remoteProps);
 		// Create an endpoint for each transport shared with the contact
diff --git a/briar-core/src/net/sf/briar/invitation/ConnectorGroup.java b/briar-core/src/net/sf/briar/invitation/ConnectorGroup.java
index ee67e4ccd3..c196597524 100644
--- a/briar-core/src/net/sf/briar/invitation/ConnectorGroup.java
+++ b/briar-core/src/net/sf/briar/invitation/ConnectorGroup.java
@@ -27,6 +27,7 @@ import net.sf.briar.api.db.DbException;
 import net.sf.briar.api.invitation.InvitationListener;
 import net.sf.briar.api.invitation.InvitationState;
 import net.sf.briar.api.invitation.InvitationTask;
+import net.sf.briar.api.messaging.GroupFactory;
 import net.sf.briar.api.plugins.PluginManager;
 import net.sf.briar.api.plugins.duplex.DuplexPlugin;
 import net.sf.briar.api.serial.ReaderFactory;
@@ -48,6 +49,7 @@ class ConnectorGroup extends Thread implements InvitationTask {
 	private final ConnectionReaderFactory connectionReaderFactory;
 	private final ConnectionWriterFactory connectionWriterFactory;
 	private final AuthorFactory authorFactory;
+	private final GroupFactory groupFactory;
 	private final KeyManager keyManager;
 	private final ConnectionDispatcher connectionDispatcher;
 	private final Clock clock;
@@ -74,9 +76,9 @@ class ConnectorGroup extends Thread implements InvitationTask {
 			ReaderFactory readerFactory, WriterFactory writerFactory,
 			ConnectionReaderFactory connectionReaderFactory,
 			ConnectionWriterFactory connectionWriterFactory,
-			AuthorFactory authorFactory, KeyManager keyManager,
-			ConnectionDispatcher connectionDispatcher, Clock clock,
-			PluginManager pluginManager, AuthorId localAuthorId,
+			AuthorFactory authorFactory, GroupFactory groupFactory,
+			KeyManager keyManager, ConnectionDispatcher connectionDispatcher,
+			Clock clock, PluginManager pluginManager, AuthorId localAuthorId,
 			int localInvitationCode, int remoteInvitationCode) {
 		super("ConnectorGroup");
 		this.crypto = crypto;
@@ -86,6 +88,7 @@ class ConnectorGroup extends Thread implements InvitationTask {
 		this.connectionReaderFactory = connectionReaderFactory;
 		this.connectionWriterFactory = connectionWriterFactory;
 		this.authorFactory = authorFactory;
+		this.groupFactory = groupFactory;
 		this.keyManager = keyManager;
 		this.connectionDispatcher = connectionDispatcher;
 		this.clock = clock;
@@ -171,8 +174,8 @@ class ConnectorGroup extends Thread implements InvitationTask {
 				remoteInvitationCode);
 		return new AliceConnector(crypto, db, readerFactory, writerFactory,
 				connectionReaderFactory, connectionWriterFactory, authorFactory,
-				keyManager, connectionDispatcher, clock, this, plugin,
-				localAuthor, localProps, random);
+				groupFactory, keyManager, connectionDispatcher, clock, this,
+				plugin, localAuthor, localProps, random);
 	}
 
 	private Connector createBobConnector(DuplexPlugin plugin,
@@ -182,8 +185,8 @@ class ConnectorGroup extends Thread implements InvitationTask {
 				localInvitationCode);
 		return new BobConnector(crypto, db, readerFactory, writerFactory,
 				connectionReaderFactory, connectionWriterFactory, authorFactory,
-				keyManager, connectionDispatcher, clock, this, plugin,
-				localAuthor, localProps, random);
+				groupFactory, keyManager, connectionDispatcher, clock, this,
+				plugin, localAuthor, localProps, random);
 	}
 
 	public void localConfirmationSucceeded() {
diff --git a/briar-core/src/net/sf/briar/invitation/InvitationTaskFactoryImpl.java b/briar-core/src/net/sf/briar/invitation/InvitationTaskFactoryImpl.java
index b3fd50162e..b798adc75e 100644
--- a/briar-core/src/net/sf/briar/invitation/InvitationTaskFactoryImpl.java
+++ b/briar-core/src/net/sf/briar/invitation/InvitationTaskFactoryImpl.java
@@ -10,6 +10,7 @@ import net.sf.briar.api.crypto.KeyManager;
 import net.sf.briar.api.db.DatabaseComponent;
 import net.sf.briar.api.invitation.InvitationTask;
 import net.sf.briar.api.invitation.InvitationTaskFactory;
+import net.sf.briar.api.messaging.GroupFactory;
 import net.sf.briar.api.plugins.PluginManager;
 import net.sf.briar.api.serial.ReaderFactory;
 import net.sf.briar.api.serial.WriterFactory;
@@ -26,6 +27,7 @@ class InvitationTaskFactoryImpl implements InvitationTaskFactory {
 	private final ConnectionReaderFactory connectionReaderFactory;
 	private final ConnectionWriterFactory connectionWriterFactory;
 	private final AuthorFactory authorFactory;
+	private final GroupFactory groupFactory;
 	private final KeyManager keyManager;
 	private final ConnectionDispatcher connectionDispatcher;
 	private final Clock clock;
@@ -36,9 +38,9 @@ class InvitationTaskFactoryImpl implements InvitationTaskFactory {
 			ReaderFactory readerFactory, WriterFactory writerFactory,
 			ConnectionReaderFactory connectionReaderFactory,
 			ConnectionWriterFactory connectionWriterFactory,
-			AuthorFactory authorFactory, KeyManager keyManager,
-			ConnectionDispatcher connectionDispatcher, Clock clock,
-			PluginManager pluginManager) {
+			AuthorFactory authorFactory, GroupFactory groupFactory,
+			KeyManager keyManager, ConnectionDispatcher connectionDispatcher,
+			Clock clock, PluginManager pluginManager) {
 		this.crypto = crypto;
 		this.db = db;
 		this.readerFactory = readerFactory;
@@ -46,6 +48,7 @@ class InvitationTaskFactoryImpl implements InvitationTaskFactory {
 		this.connectionReaderFactory = connectionReaderFactory;
 		this.connectionWriterFactory = connectionWriterFactory;
 		this.authorFactory = authorFactory;
+		this.groupFactory = groupFactory;
 		this.keyManager = keyManager;
 		this.connectionDispatcher = connectionDispatcher;
 		this.clock = clock;
@@ -56,7 +59,7 @@ class InvitationTaskFactoryImpl implements InvitationTaskFactory {
 			int remoteCode) {
 		return new ConnectorGroup(crypto, db, readerFactory, writerFactory,
 				connectionReaderFactory, connectionWriterFactory,
-				authorFactory, keyManager, connectionDispatcher, clock,
-				pluginManager, localAuthorId, localCode, remoteCode);
+				authorFactory, groupFactory, keyManager, connectionDispatcher,
+				clock, pluginManager, localAuthorId, localCode, remoteCode);
 	}
 }
diff --git a/briar-core/src/net/sf/briar/messaging/AuthorFactoryImpl.java b/briar-core/src/net/sf/briar/messaging/AuthorFactoryImpl.java
index a55a60b220..348672611b 100644
--- a/briar-core/src/net/sf/briar/messaging/AuthorFactoryImpl.java
+++ b/briar-core/src/net/sf/briar/messaging/AuthorFactoryImpl.java
@@ -27,24 +27,28 @@ class AuthorFactoryImpl implements AuthorFactory {
 		this.writerFactory = writerFactory;
 	}
 
-	public Author createAuthor(String name, byte[] publicKey)
-			throws IOException {
+	public Author createAuthor(String name, byte[] publicKey) {
 		return new Author(getId(name, publicKey), name, publicKey);
 	}
 
 	public LocalAuthor createLocalAuthor(String name, byte[] publicKey,
-			byte[] privateKey) throws IOException {
+			byte[] privateKey) {
 		return new LocalAuthor(getId(name, publicKey), name, publicKey,
 				privateKey);
 	}
 
-	private AuthorId getId(String name, byte[] publicKey) throws IOException {
+	private AuthorId getId(String name, byte[] publicKey) {
 		ByteArrayOutputStream out = new ByteArrayOutputStream();
 		Writer w = writerFactory.createWriter(out);
-		w.writeStructStart(AUTHOR);
-		w.writeString(name);
-		w.writeBytes(publicKey);
-		w.writeStructEnd();
+		try {
+			w.writeStructStart(AUTHOR);
+			w.writeString(name);
+			w.writeBytes(publicKey);
+			w.writeStructEnd();
+		} catch(IOException e) {
+			// Shouldn't happen with ByteArrayOutputStream
+			throw new RuntimeException();
+		}
 		MessageDigest messageDigest = crypto.getMessageDigest();
 		messageDigest.update(out.toByteArray());
 		return new AuthorId(messageDigest.digest());
diff --git a/briar-core/src/net/sf/briar/messaging/GroupFactoryImpl.java b/briar-core/src/net/sf/briar/messaging/GroupFactoryImpl.java
index 0a251f523d..bb08c25580 100644
--- a/briar-core/src/net/sf/briar/messaging/GroupFactoryImpl.java
+++ b/briar-core/src/net/sf/briar/messaging/GroupFactoryImpl.java
@@ -27,22 +27,28 @@ class GroupFactoryImpl implements GroupFactory {
 		this.writerFactory = writerFactory;
 	}
 
-	public Group createGroup(String name) throws IOException {
+	public Group createGroup(String name, boolean isPrivate) {
 		byte[] salt = new byte[GROUP_SALT_LENGTH];
 		crypto.getSecureRandom().nextBytes(salt);
-		return createGroup(name, salt);
+		return createGroup(name, salt, isPrivate);
 	}
 
-	public Group createGroup(String name, byte[] salt) throws IOException {
+	public Group createGroup(String name, byte[] salt, boolean isPrivate) {
 		ByteArrayOutputStream out = new ByteArrayOutputStream();
 		Writer w = writerFactory.createWriter(out);
-		w.writeStructStart(GROUP);
-		w.writeString(name);
-		w.writeBytes(salt);
-		w.writeStructEnd();
+		try {
+			w.writeStructStart(GROUP);
+			w.writeString(name);
+			w.writeBytes(salt);
+			w.writeBoolean(isPrivate);
+			w.writeStructEnd();
+		} catch(IOException e) {
+			// Shouldn't happen with ByteArrayOutputStream
+			throw new RuntimeException();
+		}
 		MessageDigest messageDigest = crypto.getMessageDigest();
 		messageDigest.update(out.toByteArray());
 		GroupId id = new GroupId(messageDigest.digest());
-		return new Group(id, name, salt);
+		return new Group(id, name, salt, isPrivate);
 	}
 }
diff --git a/briar-core/src/net/sf/briar/messaging/GroupReader.java b/briar-core/src/net/sf/briar/messaging/GroupReader.java
index 20402c5ae2..4b35b2ec92 100644
--- a/briar-core/src/net/sf/briar/messaging/GroupReader.java
+++ b/briar-core/src/net/sf/briar/messaging/GroupReader.java
@@ -31,10 +31,11 @@ class GroupReader implements StructReader<Group> {
 		byte[] publicKey = null;
 		if(r.hasNull()) r.readNull();
 		else publicKey = r.readBytes(MAX_PUBLIC_KEY_LENGTH);
+		boolean isPrivate = r.readBoolean();
 		r.readStructEnd();
 		r.removeConsumer(digesting);
 		// Build and return the group
 		GroupId id = new GroupId(messageDigest.digest());
-		return new Group(id, name, publicKey);
+		return new Group(id, name, publicKey, isPrivate);
 	}
 }
diff --git a/briar-core/src/net/sf/briar/messaging/MessageFactoryImpl.java b/briar-core/src/net/sf/briar/messaging/MessageFactoryImpl.java
index 6f4ab5b11b..cbb60ff59b 100644
--- a/briar-core/src/net/sf/briar/messaging/MessageFactoryImpl.java
+++ b/briar-core/src/net/sf/briar/messaging/MessageFactoryImpl.java
@@ -47,13 +47,6 @@ class MessageFactoryImpl implements MessageFactory {
 		this.writerFactory = writerFactory;
 	}
 
-	public Message createPrivateMessage(MessageId parent, String contentType,
-			long timestamp, byte[] body) throws IOException,
-			GeneralSecurityException {
-		return createMessage(parent, null, null, null, contentType, timestamp,
-				body);
-	}
-
 	public Message createAnonymousMessage(MessageId parent, Group group,
 			String contentType, long timestamp, byte[] body) throws IOException,
 			GeneralSecurityException {
@@ -97,8 +90,7 @@ class MessageFactoryImpl implements MessageFactory {
 		w.writeStructStart(MESSAGE);
 		if(parent == null) w.writeNull();
 		else w.writeBytes(parent.getBytes());
-		if(group == null) w.writeNull();
-		else writeGroup(w, group);
+		writeGroup(w, group);
 		if(author == null) w.writeNull();
 		else writeAuthor(w, author);
 		w.writeString(contentType);
@@ -130,6 +122,7 @@ class MessageFactoryImpl implements MessageFactory {
 		w.writeStructStart(GROUP);
 		w.writeString(g.getName());
 		w.writeBytes(g.getSalt());
+		w.writeBoolean(g.isPrivate());
 		w.writeStructEnd();
 	}
 
diff --git a/briar-core/src/net/sf/briar/messaging/MessageReader.java b/briar-core/src/net/sf/briar/messaging/MessageReader.java
index c3a6978865..365b8c4e1f 100644
--- a/briar-core/src/net/sf/briar/messaging/MessageReader.java
+++ b/briar-core/src/net/sf/briar/messaging/MessageReader.java
@@ -4,14 +4,10 @@ import static net.sf.briar.api.AuthorConstants.MAX_SIGNATURE_LENGTH;
 import static net.sf.briar.api.messaging.MessagingConstants.MAX_BODY_LENGTH;
 import static net.sf.briar.api.messaging.MessagingConstants.MAX_CONTENT_TYPE_LENGTH;
 import static net.sf.briar.api.messaging.MessagingConstants.MAX_PACKET_LENGTH;
-import static net.sf.briar.api.messaging.MessagingConstants.MAX_SUBJECT_LENGTH;
 import static net.sf.briar.api.messaging.MessagingConstants.MESSAGE_SALT_LENGTH;
 import static net.sf.briar.api.messaging.Types.MESSAGE;
 
 import java.io.IOException;
-import java.nio.ByteBuffer;
-import java.nio.charset.Charset;
-import java.nio.charset.CharsetDecoder;
 
 import net.sf.briar.api.Author;
 import net.sf.briar.api.FormatException;
@@ -28,13 +24,11 @@ class MessageReader implements StructReader<UnverifiedMessage> {
 
 	private final StructReader<Group> groupReader;
 	private final StructReader<Author> authorReader;
-	private final CharsetDecoder decoder;
 
 	MessageReader(StructReader<Group> groupReader,
 			StructReader<Author> authorReader) {
 		this.groupReader = groupReader;
 		this.authorReader = authorReader;
-		decoder = Charset.forName("UTF-8").newDecoder();
 	}
 
 	public UnverifiedMessage readStruct(Reader r) throws IOException {
@@ -53,10 +47,8 @@ class MessageReader implements StructReader<UnverifiedMessage> {
 			if(b.length < UniqueId.LENGTH) throw new FormatException();
 			parent = new MessageId(b);
 		}
-		// Read the group, if there is one
-		Group group = null;
-		if(r.hasNull()) r.readNull();
-		else group = groupReader.readStruct(r);
+		// Read the group
+		Group group = groupReader.readStruct(r);
 		// Read the author, if there is one
 		Author author = null;
 		if(r.hasNull()) r.readNull();
@@ -71,16 +63,6 @@ class MessageReader implements StructReader<UnverifiedMessage> {
 		if(salt.length < MESSAGE_SALT_LENGTH) throw new FormatException();
 		// Read the message body
 		byte[] body = r.readBytes(MAX_BODY_LENGTH);
-		// If the content type is text/plain, extract a subject line
-		String subject;
-		if(contentType.equals("text/plain")) {
-			byte[] start = new byte[Math.min(MAX_SUBJECT_LENGTH, body.length)];
-			System.arraycopy(body, 0, start, 0, start.length);
-			decoder.reset();
-			subject = decoder.decode(ByteBuffer.wrap(start)).toString();
-		} else {
-			subject = "";
-		}
 		// Record the offset of the body within the message
 		int bodyStart = (int) counting.getCount() - body.length;
 		// Record the length of the data covered by the author's signature
@@ -96,7 +78,7 @@ class MessageReader implements StructReader<UnverifiedMessage> {
 		r.removeConsumer(copying);
 		byte[] raw = copying.getCopy();
 		return new UnverifiedMessage(parent, group, author, contentType,
-				subject, timestamp, raw, signature, bodyStart, body.length,
+				timestamp, raw, signature, bodyStart, body.length,
 				signedLength);
 	}
 }
diff --git a/briar-core/src/net/sf/briar/messaging/MessageVerifierImpl.java b/briar-core/src/net/sf/briar/messaging/MessageVerifierImpl.java
index ed139c53cc..cf9f755fdb 100644
--- a/briar-core/src/net/sf/briar/messaging/MessageVerifierImpl.java
+++ b/briar-core/src/net/sf/briar/messaging/MessageVerifierImpl.java
@@ -1,10 +1,13 @@
 package net.sf.briar.messaging;
 
+import static net.sf.briar.api.transport.TransportConstants.MAX_CLOCK_DIFFERENCE;
+
 import java.security.GeneralSecurityException;
 
 import javax.inject.Inject;
 
 import net.sf.briar.api.Author;
+import net.sf.briar.api.clock.Clock;
 import net.sf.briar.api.crypto.CryptoComponent;
 import net.sf.briar.api.crypto.KeyParser;
 import net.sf.briar.api.crypto.MessageDigest;
@@ -18,11 +21,13 @@ import net.sf.briar.api.messaging.UnverifiedMessage;
 class MessageVerifierImpl implements MessageVerifier {
 
 	private final CryptoComponent crypto;
+	private final Clock clock;
 	private final KeyParser keyParser;
 
 	@Inject
-	MessageVerifierImpl(CryptoComponent crypto) {
+	MessageVerifierImpl(CryptoComponent crypto, Clock clock) {
 		this.crypto = crypto;
+		this.clock = clock;
 		keyParser = crypto.getSignatureKeyParser();
 	}
 
@@ -30,7 +35,11 @@ class MessageVerifierImpl implements MessageVerifier {
 			throws GeneralSecurityException {
 		MessageDigest messageDigest = crypto.getMessageDigest();
 		Signature signature = crypto.getSignature();
-		// Hash the message, including the signature, to get the message ID
+		// Reject the message if it's too far in the future
+		long now = clock.currentTimeMillis();
+		if(m.getTimestamp() > now + MAX_CLOCK_DIFFERENCE)
+			throw new GeneralSecurityException();
+		// Hash the message to get the message ID
 		byte[] raw = m.getSerialised();
 		messageDigest.update(raw);
 		MessageId id = new MessageId(messageDigest.digest());
diff --git a/briar-core/src/net/sf/briar/messaging/PacketWriterImpl.java b/briar-core/src/net/sf/briar/messaging/PacketWriterImpl.java
index 281d5a36b9..ffdf05b248 100644
--- a/briar-core/src/net/sf/briar/messaging/PacketWriterImpl.java
+++ b/briar-core/src/net/sf/briar/messaging/PacketWriterImpl.java
@@ -129,6 +129,7 @@ class PacketWriterImpl implements PacketWriter {
 			w.writeStructStart(GROUP);
 			w.writeString(g.getName());
 			w.writeBytes(g.getSalt());
+			w.writeBoolean(g.isPrivate());
 			w.writeStructEnd();
 		}
 		w.writeListEnd();
diff --git a/briar-core/src/net/sf/briar/messaging/SubscriptionUpdateReader.java b/briar-core/src/net/sf/briar/messaging/SubscriptionUpdateReader.java
index 1ed20b5eda..70f628392e 100644
--- a/briar-core/src/net/sf/briar/messaging/SubscriptionUpdateReader.java
+++ b/briar-core/src/net/sf/briar/messaging/SubscriptionUpdateReader.java
@@ -32,10 +32,10 @@ class SubscriptionUpdateReader implements StructReader<SubscriptionUpdate> {
 		// Read the start of the struct
 		r.readStructStart(SUBSCRIPTION_UPDATE);
 		// Read the subscriptions
-		List<Group> subs = new ArrayList<Group>();
+		List<Group> groups = new ArrayList<Group>();
 		r.readListStart();
 		for(int i = 0; i < MAX_SUBSCRIPTIONS && !r.hasListEnd(); i++)
-			subs.add(groupReader.readStruct(r));
+			groups.add(groupReader.readStruct(r));
 		r.readListEnd();
 		// Read the version number
 		long version = r.readIntAny();
@@ -45,7 +45,7 @@ class SubscriptionUpdateReader implements StructReader<SubscriptionUpdate> {
 		// Reset the reader
 		r.removeConsumer(counting);
 		// Build and return the subscription update
-		subs = Collections.unmodifiableList(subs);
-		return new SubscriptionUpdate(subs, version);
+		groups = Collections.unmodifiableList(groups);
+		return new SubscriptionUpdate(groups, version);
 	}
 }
diff --git a/briar-core/src/net/sf/briar/messaging/duplex/DuplexConnection.java b/briar-core/src/net/sf/briar/messaging/duplex/DuplexConnection.java
index 747a443484..8d70582358 100644
--- a/briar-core/src/net/sf/briar/messaging/duplex/DuplexConnection.java
+++ b/briar-core/src/net/sf/briar/messaging/duplex/DuplexConnection.java
@@ -25,12 +25,11 @@ import net.sf.briar.api.db.DbException;
 import net.sf.briar.api.db.event.ContactRemovedEvent;
 import net.sf.briar.api.db.event.DatabaseEvent;
 import net.sf.briar.api.db.event.DatabaseListener;
-import net.sf.briar.api.db.event.GroupMessageAddedEvent;
 import net.sf.briar.api.db.event.LocalSubscriptionsUpdatedEvent;
 import net.sf.briar.api.db.event.LocalTransportsUpdatedEvent;
+import net.sf.briar.api.db.event.MessageAddedEvent;
 import net.sf.briar.api.db.event.MessageExpiredEvent;
 import net.sf.briar.api.db.event.MessageReceivedEvent;
-import net.sf.briar.api.db.event.PrivateMessageAddedEvent;
 import net.sf.briar.api.db.event.RemoteRetentionTimeUpdatedEvent;
 import net.sf.briar.api.db.event.RemoteSubscriptionsUpdatedEvent;
 import net.sf.briar.api.db.event.RemoteTransportsUpdatedEvent;
@@ -129,7 +128,7 @@ abstract class DuplexConnection implements DatabaseListener {
 		if(e instanceof ContactRemovedEvent) {
 			ContactRemovedEvent c = (ContactRemovedEvent) e;
 			if(contactId.equals(c.getContactId())) writerTasks.add(CLOSE);
-		} else if(e instanceof GroupMessageAddedEvent) {
+		} else if(e instanceof MessageAddedEvent) {
 			if(canSendOffer.getAndSet(false))
 				dbExecutor.execute(new GenerateOffer());
 		} else if(e instanceof MessageExpiredEvent) {
@@ -147,12 +146,6 @@ abstract class DuplexConnection implements DatabaseListener {
 		} else if(e instanceof MessageReceivedEvent) {
 			if(((MessageReceivedEvent) e).getContactId().equals(contactId))
 				dbExecutor.execute(new GenerateAcks());
-		} else if(e instanceof PrivateMessageAddedEvent) {
-			PrivateMessageAddedEvent p = (PrivateMessageAddedEvent) e;
-			if(!p.isIncoming() && p.getContactId().equals(contactId)) {
-				if(canSendOffer.getAndSet(false))
-					dbExecutor.execute(new GenerateOffer());
-			}
 		} else if(e instanceof RemoteRetentionTimeUpdatedEvent) {
 			dbExecutor.execute(new GenerateRetentionAck());
 		} else if(e instanceof RemoteSubscriptionsUpdatedEvent) {
diff --git a/briar-tests/src/net/sf/briar/ProtocolIntegrationTest.java b/briar-tests/src/net/sf/briar/ProtocolIntegrationTest.java
index 1a4b1c6590..fa108875db 100644
--- a/briar-tests/src/net/sf/briar/ProtocolIntegrationTest.java
+++ b/briar-tests/src/net/sf/briar/ProtocolIntegrationTest.java
@@ -95,7 +95,7 @@ public class ProtocolIntegrationTest extends BriarTestCase {
 		new Random().nextBytes(secret);
 		// Create a group
 		GroupFactory groupFactory = i.getInstance(GroupFactory.class);
-		group = groupFactory.createGroup("Group");
+		group = groupFactory.createGroup("Group", false);
 		// Create an author
 		AuthorFactory authorFactory = i.getInstance(AuthorFactory.class);
 		CryptoComponent crypto = i.getInstance(CryptoComponent.class);
diff --git a/briar-tests/src/net/sf/briar/db/DatabaseComponentTest.java b/briar-tests/src/net/sf/briar/db/DatabaseComponentTest.java
index cc6a37ab37..d33f8b29e6 100644
--- a/briar-tests/src/net/sf/briar/db/DatabaseComponentTest.java
+++ b/briar-tests/src/net/sf/briar/db/DatabaseComponentTest.java
@@ -31,11 +31,11 @@ import net.sf.briar.api.db.NoSuchTransportException;
 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.GroupMessageAddedEvent;
 import net.sf.briar.api.db.event.LocalAuthorAddedEvent;
 import net.sf.briar.api.db.event.LocalAuthorRemovedEvent;
 import net.sf.briar.api.db.event.LocalSubscriptionsUpdatedEvent;
-import net.sf.briar.api.db.event.PrivateMessageAddedEvent;
+import net.sf.briar.api.db.event.MessageAddedEvent;
+import net.sf.briar.api.db.event.MessageReceivedEvent;
 import net.sf.briar.api.db.event.SubscriptionAddedEvent;
 import net.sf.briar.api.db.event.SubscriptionRemovedEvent;
 import net.sf.briar.api.lifecycle.ShutdownManager;
@@ -68,12 +68,12 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 	protected final Author author;
 	protected final AuthorId localAuthorId;
 	protected final LocalAuthor localAuthor;
-	protected final MessageId messageId, messageId1, privateMessageId;
+	protected final MessageId messageId, messageId1;
 	protected final String contentType, subject;
 	protected final long timestamp;
 	protected final int size;
 	protected final byte[] raw;
-	protected final Message message, privateMessage;
+	protected final Message message, message1;
 	protected final TransportId transportId;
 	protected final TransportProperties transportProperties;
 	protected final ContactId contactId;
@@ -83,7 +83,7 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 
 	public DatabaseComponentTest() {
 		groupId = new GroupId(TestUtils.getRandomId());
-		group = new Group(groupId, "Group", new byte[GROUP_SALT_LENGTH]);
+		group = new Group(groupId, "Group", new byte[GROUP_SALT_LENGTH], true);
 		authorId = new AuthorId(TestUtils.getRandomId());
 		author = new Author(authorId, "Alice", new byte[MAX_PUBLIC_KEY_LENGTH]);
 		localAuthorId = new AuthorId(TestUtils.getRandomId());
@@ -91,7 +91,6 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 				new byte[MAX_PUBLIC_KEY_LENGTH], new byte[100]);
 		messageId = new MessageId(TestUtils.getRandomId());
 		messageId1 = new MessageId(TestUtils.getRandomId());
-		privateMessageId = new MessageId(TestUtils.getRandomId());
 		contentType = "text/plain";
 		subject = "Foo";
 		timestamp = System.currentTimeMillis();
@@ -99,7 +98,7 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		raw = new byte[size];
 		message = new TestMessage(messageId, null, group, author, contentType,
 				subject, timestamp, raw);
-		privateMessage = new TestMessage(privateMessageId, null, null, null,
+		message1 = new TestMessage(messageId1, messageId, group, null,
 				contentType, subject, timestamp, raw);
 		transportId = new TransportId(TestUtils.getRandomId());
 		transportProperties = new TransportProperties(Collections.singletonMap(
@@ -136,13 +135,13 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 					with(any(long.class)));
 			oneOf(shutdown).addShutdownHook(with(any(Runnable.class)));
 			will(returnValue(shutdownHandle));
-			// addLocalAuthor(localAuthor)
+			// addLocalAuthor()
 			oneOf(database).containsLocalAuthor(txn, localAuthorId);
 			will(returnValue(false));
 			oneOf(database).addLocalAuthor(txn, localAuthor);
 			oneOf(listener).eventOccurred(with(any(
 					LocalAuthorAddedEvent.class)));
-			// addContact(author, localAuthorId)
+			// addContact()
 			oneOf(database).containsContact(txn, authorId);
 			will(returnValue(false));
 			oneOf(database).containsLocalAuthor(txn, localAuthorId);
@@ -153,43 +152,43 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 			// getContacts()
 			oneOf(database).getContacts(txn);
 			will(returnValue(Arrays.asList(contact)));
-			// getRemoteProperties(transportId)
+			// getRemoteProperties()
 			oneOf(database).getRemoteProperties(txn, transportId);
 			will(returnValue(Collections.emptyMap()));
-			// subscribe(group)
-			oneOf(database).containsSubscription(txn, groupId);
+			// addGroup()
+			oneOf(database).containsGroup(txn, groupId);
 			will(returnValue(false));
-			oneOf(database).addSubscription(txn, group);
+			oneOf(database).addGroup(txn, group);
 			will(returnValue(true));
 			oneOf(listener).eventOccurred(with(any(
 					SubscriptionAddedEvent.class)));
-			// subscribe(group) again
-			oneOf(database).containsSubscription(txn, groupId);
+			// addGroup() again
+			oneOf(database).containsGroup(txn, groupId);
 			will(returnValue(true));
-			// getMessageHeaders(groupId)
-			oneOf(database).containsSubscription(txn, groupId);
+			// getMessageHeaders()
+			oneOf(database).containsGroup(txn, groupId);
 			will(returnValue(true));
-			oneOf(database).getGroupMessageHeaders(txn, groupId);
+			oneOf(database).getMessageHeaders(txn, groupId);
 			will(returnValue(Collections.emptyList()));
-			// getSubscriptions()
-			oneOf(database).getSubscriptions(txn);
+			// getGroups()
+			oneOf(database).getGroups(txn);
 			will(returnValue(Arrays.asList(groupId)));
-			// unsubscribe(groupId)
-			oneOf(database).containsSubscription(txn, groupId);
+			// removeGroup()
+			oneOf(database).containsGroup(txn, groupId);
 			will(returnValue(true));
 			oneOf(database).getVisibility(txn, groupId);
 			will(returnValue(Collections.emptyList()));
-			oneOf(database).removeSubscription(txn, groupId);
+			oneOf(database).removeGroup(txn, groupId);
 			oneOf(listener).eventOccurred(with(any(
 					SubscriptionRemovedEvent.class)));
 			oneOf(listener).eventOccurred(with(any(
 					LocalSubscriptionsUpdatedEvent.class)));
-			// removeContact(contactId)
+			// removeContact()
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
 			oneOf(database).removeContact(txn, contactId);
 			oneOf(listener).eventOccurred(with(any(ContactRemovedEvent.class)));
-			// removeLocalAuthor(localAuthorId)
+			// removeLocalAuthor()
 			oneOf(database).containsLocalAuthor(txn, localAuthorId);
 			will(returnValue(true));
 			oneOf(database).getContacts(txn, localAuthorId);
@@ -212,12 +211,11 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		assertEquals(Arrays.asList(contact), 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.getGroupMessageHeaders(groupId));
-		assertEquals(Arrays.asList(groupId), db.getSubscriptions());
-		db.unsubscribe(group);
+		db.addGroup(group); // First time - listeners called
+		db.addGroup(group); // Second time - not called
+		assertEquals(Collections.emptyList(), db.getMessageHeaders(groupId));
+		assertEquals(Arrays.asList(groupId), db.getGroups());
+		db.removeGroup(group);
 		db.removeContact(contactId);
 		db.removeLocalAuthor(localAuthorId);
 		db.removeListener(listener);
@@ -227,133 +225,82 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 	}
 
 	@Test
-	public void testGroupMessagesAreNotStoredUnlessSubscribed()
-			throws Exception {
+	public void testDuplicateLocalMessagesAreNotStored() 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);
 		context.checking(new Expectations() {{
-			// addLocalGroupMessage(message)
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).containsSubscription(txn, groupId);
-			will(returnValue(false));
+			oneOf(database).containsMessage(txn, messageId);
+			will(returnValue(true));
 			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
 
-		db.addLocalGroupMessage(message);
+		db.addLocalMessage(message);
 
 		context.assertIsSatisfied();
 	}
 
 	@Test
-	public void testDuplicateGroupMessagesAreNotStored() throws Exception {
+	public void testLocalMessagesAreNotStoredUnlessSubscribed()
+			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);
 		context.checking(new Expectations() {{
-			// addLocalGroupMessage(message)
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).containsSubscription(txn, groupId);
-			will(returnValue(true));
-			oneOf(database).addGroupMessage(txn, message, false);
+			oneOf(database).containsMessage(txn, messageId);
+			will(returnValue(false));
+			oneOf(database).containsGroup(txn, groupId);
 			will(returnValue(false));
 			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
 
-		db.addLocalGroupMessage(message);
+		db.addLocalMessage(message);
 
 		context.assertIsSatisfied();
 	}
 
 	@Test
-	public void testAddLocalGroupMessage() throws Exception {
+	public void testAddLocalMessage() 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 DatabaseListener listener = context.mock(DatabaseListener.class);
 		context.checking(new Expectations() {{
-			// addLocalGroupMessage(message)
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).containsSubscription(txn, groupId);
-			will(returnValue(true));
-			oneOf(database).addGroupMessage(txn, message, false);
+			oneOf(database).containsMessage(txn, messageId);
+			will(returnValue(false));
+			oneOf(database).containsGroup(txn, groupId);
 			will(returnValue(true));
+			oneOf(database).addMessage(txn, message, false);
 			oneOf(database).setReadFlag(txn, messageId, true);
 			oneOf(database).getContactIds(txn);
 			will(returnValue(Arrays.asList(contactId)));
 			oneOf(database).addStatus(txn, contactId, messageId, false);
 			oneOf(database).commitTransaction(txn);
+			// The message was added, so the listener should be called
+			oneOf(listener).eventOccurred(with(any(
+					MessageAddedEvent.class)));
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
 
-		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);
-		context.checking(new Expectations() {{
-			oneOf(database).startTransaction();
-			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
-			oneOf(database).containsContact(txn, contactId);
-			will(returnValue(true));
-			// addLocalPrivateMessage(privateMessage, contactId)
-			oneOf(database).addPrivateMessage(txn, privateMessage, contactId,
-					false);
-			will(returnValue(false));
-		}});
-		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				shutdown);
-
-		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);
-		context.checking(new Expectations() {{
-			oneOf(database).startTransaction();
-			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
-			oneOf(database).containsContact(txn, contactId);
-			will(returnValue(true));
-			// addLocalPrivateMessage(privateMessage, contactId)
-			oneOf(database).addPrivateMessage(txn, privateMessage, contactId,
-					false);
-			will(returnValue(true));
-			oneOf(database).setReadFlag(txn, privateMessageId, true);
-			oneOf(database).addStatus(txn, contactId, privateMessageId, false);
-		}});
-		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				shutdown);
-
-		db.addLocalPrivateMessage(privateMessage, contactId);
+		db.addListener(listener);
+		db.addLocalMessage(message);
 
 		context.assertIsSatisfied();
 	}
@@ -383,7 +330,7 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		} catch(NoSuchContactException expected) {}
 
 		try {
-			db.addLocalPrivateMessage(privateMessage, contactId);
+			db.containsSendableMessages(contactId);
 			fail();
 		} catch(NoSuchContactException expected) {}
 
@@ -443,12 +390,7 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		} catch(NoSuchContactException expected) {}
 
 		try {
-			db.getVisibleSubscriptions(contactId);
-			fail();
-		} catch(NoSuchContactException expected) {}
-
-		try {
-			db.hasSendableMessages(contactId);
+			db.getInboxGroup(contactId);
 			fail();
 		} catch(NoSuchContactException expected) {}
 
@@ -522,6 +464,11 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 			fail();
 		} catch(NoSuchContactException expected) {}
 
+		try {
+			db.setInboxGroup(contactId, group);
+			fail();
+		} catch(NoSuchContactException expected) {}
+
 		try {
 			db.setSeen(contactId, Arrays.asList(messageId));
 			fail();
@@ -582,7 +529,7 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 			// Check whether the subscription is in the DB (which it's not)
 			exactly(5).of(database).startTransaction();
 			will(returnValue(txn));
-			exactly(5).of(database).containsSubscription(txn, groupId);
+			exactly(5).of(database).containsGroup(txn, groupId);
 			will(returnValue(false));
 			exactly(5).of(database).abortTransaction(txn);
 		}});
@@ -595,7 +542,7 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		} catch(NoSuchSubscriptionException expected) {}
 
 		try {
-			db.getGroupMessageHeaders(groupId);
+			db.getMessageHeaders(groupId);
 			fail();
 		} catch(NoSuchSubscriptionException expected) {}
 
@@ -605,12 +552,12 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		} catch(NoSuchSubscriptionException expected) {}
 
 		try {
-			db.setVisibility(groupId, Collections.<ContactId>emptyList());
+			db.removeGroup(group);
 			fail();
 		} catch(NoSuchSubscriptionException expected) {}
 
 		try {
-			db.unsubscribe(group);
+			db.setVisibility(groupId, Collections.<ContactId>emptyList());
 			fail();
 		} catch(NoSuchSubscriptionException expected) {}
 
@@ -626,14 +573,14 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		final DatabaseCleaner cleaner = context.mock(DatabaseCleaner.class);
 		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
 		context.checking(new Expectations() {{
-			// addLocalAuthor(localAuthor)
+			// addLocalAuthor()
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
 			oneOf(database).containsLocalAuthor(txn, localAuthorId);
 			will(returnValue(false));
 			oneOf(database).addLocalAuthor(txn, localAuthor);
 			oneOf(database).commitTransaction(txn);
-			// addContact(author, localAuthorId)
+			// addContact()
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
 			oneOf(database).containsContact(txn, authorId);
@@ -830,12 +777,12 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
 			// Get the sendable message IDs
 			oneOf(database).getMessagesToOffer(txn, contactId, 123);
 			will(returnValue(messagesToOffer));
+			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
@@ -856,11 +803,11 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
 			oneOf(database).getRetentionUpdate(txn, contactId, Long.MAX_VALUE);
 			will(returnValue(null));
+			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
@@ -880,11 +827,11 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
 			oneOf(database).getRetentionUpdate(txn, contactId, Long.MAX_VALUE);
 			will(returnValue(new RetentionUpdate(0, 1)));
+			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
@@ -907,12 +854,12 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
 			oneOf(database).getSubscriptionUpdate(txn, contactId,
 					Long.MAX_VALUE);
 			will(returnValue(null));
+			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
@@ -932,12 +879,12 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
 			oneOf(database).getSubscriptionUpdate(txn, contactId,
 					Long.MAX_VALUE);
 			will(returnValue(new SubscriptionUpdate(Arrays.asList(group), 1)));
+			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
@@ -960,11 +907,11 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
 			oneOf(database).getTransportUpdates(txn, contactId, Long.MAX_VALUE);
 			will(returnValue(null));
+			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
@@ -984,12 +931,12 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
 			oneOf(database).getTransportUpdates(txn, contactId, Long.MAX_VALUE);
 			will(returnValue(Arrays.asList(new TransportUpdate(transportId,
 					transportProperties, 1))));
+			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
@@ -1016,12 +963,10 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
-			// Get the acked messages
-			oneOf(database).removeOutstandingMessages(txn, contactId,
-					Arrays.asList(messageId));
+			oneOf(database).setStatusSeenIfVisible(txn, contactId, messageId);
+			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
@@ -1032,65 +977,77 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 	}
 
 	@Test
-	public void testReceivePrivateMessage() throws Exception {
+	public void testReceiveMessage() 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 DatabaseListener listener = context.mock(DatabaseListener.class);
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
-			// The message is stored
-			oneOf(database).addPrivateMessage(txn, privateMessage, contactId,
-					true);
+			oneOf(database).containsMessage(txn, messageId);
+			will(returnValue(false));
+			oneOf(database).containsVisibleGroup(txn, contactId, groupId);
 			will(returnValue(true));
-			oneOf(database).addStatus(txn, contactId, privateMessageId, true);
-			// The message must be acked
-			oneOf(database).addMessageToAck(txn, contactId, privateMessageId);
+			oneOf(database).addMessage(txn, message, true);
+			oneOf(database).addStatus(txn, contactId, messageId, true);
+			oneOf(database).getContactIds(txn);
+			will(returnValue(Arrays.asList(contactId)));
+			oneOf(database).addMessageToAck(txn, contactId, messageId);
+			oneOf(database).commitTransaction(txn);
+			// The message was received and added
+			oneOf(listener).eventOccurred(with(any(
+					MessageReceivedEvent.class)));
+			oneOf(listener).eventOccurred(with(any(MessageAddedEvent.class)));
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
 
-		db.receiveMessage(contactId, privateMessage);
+		db.addListener(listener);
+		db.receiveMessage(contactId, message);
 
 		context.assertIsSatisfied();
 	}
 
 	@Test
-	public void testReceiveDuplicatePrivateMessage() throws Exception {
+	public void testReceiveDuplicateMessage() 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 DatabaseListener listener = context.mock(DatabaseListener.class);
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
-			// The message is not stored, it's a duplicate
-			oneOf(database).addPrivateMessage(txn, privateMessage, contactId,
-					true);
-			will(returnValue(false));
-			// The message must still be acked
-			oneOf(database).addMessageToAck(txn, contactId, privateMessageId);
+			oneOf(database).containsMessage(txn, messageId);
+			will(returnValue(true));
+			oneOf(database).containsVisibleGroup(txn, contactId, groupId);
+			will(returnValue(true));
+			// The message wasn't stored but it must still be acked
+			oneOf(database).addMessageToAck(txn, contactId, messageId);
+			oneOf(database).commitTransaction(txn);
+			// The message was received but not added
+			oneOf(listener).eventOccurred(with(any(
+					MessageReceivedEvent.class)));
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
 
-		db.receiveMessage(contactId, privateMessage);
+		db.addListener(listener);
+		db.receiveMessage(contactId, message);
 
 		context.assertIsSatisfied();
 	}
 
 	@Test
-	public void testReceiveMessageDoesNotStoreGroupMessageUnlessSubscribed()
-			throws Exception {
+	public void testReceiveMessageWithoutVisibleGroup() throws Exception {
 		Mockery context = new Mockery();
 		@SuppressWarnings("unchecked")
 		final Database<Object> database = context.mock(Database.class);
@@ -1099,15 +1056,13 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
-			// Only store messages belonging to visible, subscribed groups
-			oneOf(database).containsVisibleSubscription(txn, contactId,
-					groupId);
+			oneOf(database).containsMessage(txn, messageId);
 			will(returnValue(false));
-			// The message is not stored but it must still be acked
-			oneOf(database).addMessageToAck(txn, contactId, messageId);
+			oneOf(database).containsVisibleGroup(txn, contactId, groupId);
+			will(returnValue(false));
+			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
@@ -1132,7 +1087,6 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
 			// Get the offered messages
@@ -1142,6 +1096,7 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 			will(returnValue(true)); // Visible - ack message # 1
 			oneOf(database).setStatusSeenIfVisible(txn, contactId, messageId2);
 			will(returnValue(false)); // Not visible - request message # 2
+			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
@@ -1168,10 +1123,10 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
 			oneOf(database).setRetentionUpdateAcked(txn, contactId, 1);
+			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
@@ -1192,10 +1147,10 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
 			oneOf(database).setSubscriptionUpdateAcked(txn, contactId, 1);
+			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
@@ -1216,11 +1171,10 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
-			oneOf(database).setSubscriptions(txn, contactId,
-					Arrays.asList(group), 1);
+			oneOf(database).setGroups(txn, contactId, Arrays.asList(group), 1);
+			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
@@ -1241,13 +1195,13 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
 			oneOf(database).containsTransport(txn, transportId);
 			will(returnValue(true));
 			oneOf(database).setTransportUpdateAcked(txn, contactId,
 					transportId, 1);
+			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
@@ -1268,11 +1222,11 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
 			oneOf(database).setRemoteProperties(txn, contactId, transportId,
 					transportProperties, 1);
+			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
@@ -1284,132 +1238,6 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		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 DatabaseListener listener = context.mock(DatabaseListener.class);
-		context.checking(new Expectations() {{
-			// addLocalGroupMessage(message)
-			oneOf(database).startTransaction();
-			will(returnValue(txn));
-			oneOf(database).containsSubscription(txn, groupId);
-			will(returnValue(true));
-			oneOf(database).addGroupMessage(txn, message, false);
-			will(returnValue(true));
-			oneOf(database).setReadFlag(txn, messageId, true);
-			oneOf(database).getContactIds(txn);
-			will(returnValue(Arrays.asList(contactId)));
-			oneOf(database).addStatus(txn, contactId, messageId, false);
-			oneOf(database).commitTransaction(txn);
-			// The message was added, so the listener should be called
-			oneOf(listener).eventOccurred(with(any(
-					GroupMessageAddedEvent.class)));
-		}});
-		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				shutdown);
-
-		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 DatabaseListener listener = context.mock(DatabaseListener.class);
-		context.checking(new Expectations() {{
-			oneOf(database).startTransaction();
-			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
-			oneOf(database).containsContact(txn, contactId);
-			will(returnValue(true));
-			// addLocalPrivateMessage(privateMessage, contactId)
-			oneOf(database).addPrivateMessage(txn, privateMessage, contactId,
-					false);
-			will(returnValue(true));
-			oneOf(database).setReadFlag(txn, privateMessageId, true);
-			oneOf(database).addStatus(txn, contactId, privateMessageId, false);
-			// The message was added, so the listener should be called
-			oneOf(listener).eventOccurred(with(any(
-					PrivateMessageAddedEvent.class)));
-		}});
-		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				shutdown);
-
-		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 DatabaseListener listener = context.mock(DatabaseListener.class);
-		context.checking(new Expectations() {{
-			// addLocalGroupMessage(message)
-			oneOf(database).startTransaction();
-			will(returnValue(txn));
-			oneOf(database).containsSubscription(txn, groupId);
-			will(returnValue(true));
-			oneOf(database).addGroupMessage(txn, message, false);
-			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);
-
-		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 DatabaseListener listener = context.mock(DatabaseListener.class);
-		context.checking(new Expectations() {{
-			oneOf(database).startTransaction();
-			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
-			oneOf(database).containsContact(txn, contactId);
-			will(returnValue(true));
-			// addLocalPrivateMessage(privateMessage, contactId)
-			oneOf(database).addPrivateMessage(txn, privateMessage, contactId,
-					false);
-			will(returnValue(false));
-			// The message was not added, so the listener should not be called
-		}});
-		DatabaseComponent db = createDatabaseComponent(database, cleaner,
-				shutdown);
-
-		db.addListener(listener);
-		db.addLocalPrivateMessage(privateMessage, contactId);
-
-		context.assertIsSatisfied();
-	}
-
 	@Test
 	public void testChangingLocalTransportPropertiesCallsListeners()
 			throws Exception {
@@ -1477,11 +1305,10 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).commitTransaction(txn);
 			oneOf(database).containsContact(txn, contactId);
 			will(returnValue(true));
-			// setSeen(contactId, Arrays.asList(messageId))
 			oneOf(database).setStatusSeenIfVisible(txn, contactId, messageId);
+			oneOf(database).commitTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
@@ -1504,7 +1331,7 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).containsSubscription(txn, groupId);
+			oneOf(database).containsGroup(txn, groupId);
 			will(returnValue(true));
 			oneOf(database).getVisibility(txn, groupId);
 			will(returnValue(both));
@@ -1539,7 +1366,7 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.checking(new Expectations() {{
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).containsSubscription(txn, groupId);
+			oneOf(database).containsGroup(txn, groupId);
 			will(returnValue(true));
 			oneOf(database).getVisibility(txn, groupId);
 			will(returnValue(both));
@@ -1558,7 +1385,7 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 	}
 
 	@Test
-	public void testSettingVisibleToAllTrueAffectsCurrentContacts()
+	public void testSettingVisibleToAllAffectsCurrentContacts()
 			throws Exception {
 		final ContactId contactId1 = new ContactId(123);
 		final Collection<ContactId> both = Arrays.asList(contactId, contactId1);
@@ -1572,7 +1399,7 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 			// setVisibility()
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).containsSubscription(txn, groupId);
+			oneOf(database).containsGroup(txn, groupId);
 			will(returnValue(true));
 			oneOf(database).getVisibility(txn, groupId);
 			will(returnValue(Collections.emptyList()));
@@ -1586,7 +1413,7 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 			// setVisibleToAll()
 			oneOf(database).startTransaction();
 			will(returnValue(txn));
-			oneOf(database).containsSubscription(txn, groupId);
+			oneOf(database).containsGroup(txn, groupId);
 			will(returnValue(true));
 			oneOf(database).setVisibleToAll(txn, groupId, true);
 			oneOf(database).getVisibility(txn, groupId);
diff --git a/briar-tests/src/net/sf/briar/db/H2DatabaseTest.java b/briar-tests/src/net/sf/briar/db/H2DatabaseTest.java
index 82e9ef580b..1d29d1a7fe 100644
--- a/briar-tests/src/net/sf/briar/db/H2DatabaseTest.java
+++ b/briar-tests/src/net/sf/briar/db/H2DatabaseTest.java
@@ -33,7 +33,7 @@ import net.sf.briar.api.TransportId;
 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.GroupMessageHeader;
+import net.sf.briar.api.db.MessageHeader;
 import net.sf.briar.api.messaging.Group;
 import net.sf.briar.api.messaging.GroupId;
 import net.sf.briar.api.messaging.GroupStatus;
@@ -59,25 +59,24 @@ public class H2DatabaseTest extends BriarTestCase {
 	private final Author author;
 	private final AuthorId localAuthorId;
 	private final LocalAuthor localAuthor;
-	private final MessageId messageId, messageId1;
+	private final MessageId messageId;
 	private final String contentType, subject;
 	private final long timestamp;
 	private final int size;
 	private final byte[] raw;
-	private final Message message, privateMessage;
+	private final Message message;
 	private final TransportId transportId;
 	private final ContactId contactId;
 
 	public H2DatabaseTest() throws Exception {
 		groupId = new GroupId(TestUtils.getRandomId());
-		group = new Group(groupId, "Group", new byte[GROUP_SALT_LENGTH]);
+		group = new Group(groupId, "Group", new byte[GROUP_SALT_LENGTH], false);
 		authorId = new AuthorId(TestUtils.getRandomId());
 		author = new Author(authorId, "Alice", new byte[MAX_PUBLIC_KEY_LENGTH]);
 		localAuthorId = new AuthorId(TestUtils.getRandomId());
 		localAuthor = new LocalAuthor(localAuthorId, "Bob",
 				new byte[MAX_PUBLIC_KEY_LENGTH], new byte[100]);
 		messageId = new MessageId(TestUtils.getRandomId());
-		messageId1 = new MessageId(TestUtils.getRandomId());
 		contentType = "text/plain";
 		subject = "Foo";
 		timestamp = System.currentTimeMillis();
@@ -86,8 +85,6 @@ public class H2DatabaseTest extends BriarTestCase {
 		random.nextBytes(raw);
 		message = new TestMessage(messageId, null, group, author, contentType,
 				subject, timestamp, raw);
-		privateMessage = new TestMessage(messageId1, null, null, null,
-				contentType, subject, timestamp, raw);
 		transportId = new TransportId(TestUtils.getRandomId());
 		contactId = new ContactId(1);
 	}
@@ -106,15 +103,12 @@ public class H2DatabaseTest extends BriarTestCase {
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
 		assertTrue(db.containsContact(txn, contactId));
-		assertFalse(db.containsSubscription(txn, groupId));
-		db.addSubscription(txn, group);
-		assertTrue(db.containsSubscription(txn, groupId));
+		assertFalse(db.containsGroup(txn, groupId));
+		db.addGroup(txn, group);
+		assertTrue(db.containsGroup(txn, groupId));
 		assertFalse(db.containsMessage(txn, messageId));
-		db.addGroupMessage(txn, message, true);
+		db.addMessage(txn, message, true);
 		assertTrue(db.containsMessage(txn, messageId));
-		assertFalse(db.containsMessage(txn, messageId1));
-		db.addPrivateMessage(txn, privateMessage, contactId, true);
-		assertTrue(db.containsMessage(txn, messageId1));
 		db.commitTransaction(txn);
 		db.close();
 
@@ -122,18 +116,14 @@ public class H2DatabaseTest extends BriarTestCase {
 		db = open(true);
 		txn = db.startTransaction();
 		assertTrue(db.containsContact(txn, contactId));
-		assertTrue(db.containsSubscription(txn, groupId));
+		assertTrue(db.containsGroup(txn, groupId));
 		assertTrue(db.containsMessage(txn, messageId));
 		byte[] raw1 = db.getRawMessage(txn, messageId);
 		assertArrayEquals(raw, raw1);
-		assertTrue(db.containsMessage(txn, messageId1));
-		raw1 = db.getRawMessage(txn, messageId1);
-		assertArrayEquals(raw, raw1);
 		// Delete the records
 		db.removeMessage(txn, messageId);
-		db.removeMessage(txn, messageId1);
 		db.removeContact(txn, contactId);
-		db.removeSubscription(txn, groupId);
+		db.removeGroup(txn, groupId);
 		db.commitTransaction(txn);
 		db.close();
 
@@ -143,25 +133,24 @@ public class H2DatabaseTest extends BriarTestCase {
 		assertFalse(db.containsContact(txn, contactId));
 		assertEquals(Collections.emptyMap(),
 				db.getRemoteProperties(txn, transportId));
-		assertFalse(db.containsSubscription(txn, groupId));
+		assertFalse(db.containsGroup(txn, groupId));
 		assertFalse(db.containsMessage(txn, messageId));
-		assertFalse(db.containsMessage(txn, messageId1));
 		db.commitTransaction(txn);
 		db.close();
 	}
 
 	@Test
-	public void testUnsubscribingRemovesGroupMessage() throws Exception {
+	public void testUnsubscribingRemovesMessage() 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, false);
+		db.addGroup(txn, group);
+		db.addMessage(txn, message, false);
 
 		// Unsubscribing from the group should remove the message
 		assertTrue(db.containsMessage(txn, messageId));
-		db.removeSubscription(txn, groupId);
+		db.removeGroup(txn, groupId);
 		assertFalse(db.containsMessage(txn, messageId));
 
 		db.commitTransaction(txn);
@@ -169,105 +158,27 @@ public class H2DatabaseTest extends BriarTestCase {
 	}
 
 	@Test
-	public void testRemovingContactRemovesPrivateMessage() throws Exception {
-		Database<Connection> db = open(false);
-		Connection txn = db.startTransaction();
-
-		// Add a contact and store a private message
-		db.addLocalAuthor(txn, localAuthor);
-		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addPrivateMessage(txn, privateMessage, contactId, false);
-
-		// Removing the contact should remove the message
-		assertTrue(db.containsMessage(txn, messageId1));
-		db.removeContact(txn, contactId);
-		assertFalse(db.containsMessage(txn, messageId1));
-
-		db.commitTransaction(txn);
-		db.close();
-	}
-
-	@Test
-	public void testSendablePrivateMessagesMustHaveSeenFlagFalse()
-			throws Exception {
-		Database<Connection> db = open(false);
-		Connection txn = db.startTransaction();
-
-		// Add a contact and store a private message
-		db.addLocalAuthor(txn, localAuthor);
-		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addPrivateMessage(txn, privateMessage, contactId, false);
-
-		// 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());
-
-		// Adding a status with seen = false should make the message sendable
-		db.addStatus(txn, contactId, messageId1, false);
-		assertTrue(db.hasSendableMessages(txn, contactId));
-		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
-		assertTrue(it.hasNext());
-		assertEquals(messageId1, it.next());
-		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
-		db.addLocalAuthor(txn, localAuthor);
-		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addPrivateMessage(txn, privateMessage, contactId, false);
-		db.addStatus(txn, contactId, messageId1, false);
-
-		// 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(messageId1, it.next());
-		assertFalse(it.hasNext());
-
-		db.commitTransaction(txn);
-		db.close();
-	}
-
-	@Test
-	public void testSendableGroupMessagesMustHaveSeenFlagFalse()
-			throws Exception {
+	public void testSendableMessagesMustHaveSeenFlagFalse() throws Exception {
 		Database<Connection> db = open(false);
 		Connection txn = db.startTransaction();
 
 		// Add a contact, subscribe to a group and store a message
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addSubscription(txn, group);
+		db.addGroup(txn, group);
 		db.addVisibility(txn, contactId, groupId);
-		db.setSubscriptions(txn, contactId, Arrays.asList(group), 1);
-		db.addGroupMessage(txn, message, false);
+		db.setGroups(txn, contactId, Arrays.asList(group), 1);
+		db.addMessage(txn, message, false);
 
 		// The message has no status yet, so it should not be sendable
-		assertFalse(db.hasSendableMessages(txn, contactId));
+		assertFalse(db.containsSendableMessages(txn, contactId));
 		Iterator<MessageId> it =
 				db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
 		assertFalse(it.hasNext());
 
 		// Adding a status with seen = false should make the message sendable
 		db.addStatus(txn, contactId, messageId, false);
-		assertTrue(db.hasSendableMessages(txn, contactId));
+		assertTrue(db.containsSendableMessages(txn, contactId));
 		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
 		assertTrue(it.hasNext());
 		assertEquals(messageId, it.next());
@@ -275,7 +186,7 @@ public class H2DatabaseTest extends BriarTestCase {
 
 		// Changing the status to seen = true should make the message unsendable
 		db.setStatusSeenIfVisible(txn, contactId, messageId);
-		assertFalse(db.hasSendableMessages(txn, contactId));
+		assertFalse(db.containsSendableMessages(txn, contactId));
 		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
 		assertFalse(it.hasNext());
 
@@ -284,35 +195,35 @@ public class H2DatabaseTest extends BriarTestCase {
 	}
 
 	@Test
-	public void testSendableGroupMessagesMustBeSubscribed() throws Exception {
+	public void testSendableMessagesMustBeSubscribed() throws Exception {
 		Database<Connection> db = open(false);
 		Connection txn = db.startTransaction();
 
 		// Add a contact, subscribe to a group and store a message
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addSubscription(txn, group);
+		db.addGroup(txn, group);
 		db.addVisibility(txn, contactId, groupId);
-		db.addGroupMessage(txn, message, false);
+		db.addMessage(txn, message, false);
 		db.addStatus(txn, contactId, messageId, false);
 
 		// The contact is not subscribed, so the message should not be sendable
-		assertFalse(db.hasSendableMessages(txn, contactId));
+		assertFalse(db.containsSendableMessages(txn, contactId));
 		Iterator<MessageId> it =
 				db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
 		assertFalse(it.hasNext());
 
 		// The contact subscribing should make the message sendable
-		db.setSubscriptions(txn, contactId, Arrays.asList(group), 1);
-		assertTrue(db.hasSendableMessages(txn, contactId));
+		db.setGroups(txn, contactId, Arrays.asList(group), 1);
+		assertTrue(db.containsSendableMessages(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.setSubscriptions(txn, contactId, Collections.<Group>emptyList(), 2);
-		assertFalse(db.hasSendableMessages(txn, contactId));
+		db.setGroups(txn, contactId, Collections.<Group>emptyList(), 2);
+		assertFalse(db.containsSendableMessages(txn, contactId));
 		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
 		assertFalse(it.hasNext());
 
@@ -321,27 +232,27 @@ public class H2DatabaseTest extends BriarTestCase {
 	}
 
 	@Test
-	public void testSendableGroupMessagesMustFitCapacity() throws Exception {
+	public void testSendableMessagesMustFitCapacity() throws Exception {
 		Database<Connection> db = open(false);
 		Connection txn = db.startTransaction();
 
 		// Add a contact, subscribe to a group and store a message
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addSubscription(txn, group);
+		db.addGroup(txn, group);
 		db.addVisibility(txn, contactId, groupId);
-		db.setSubscriptions(txn, contactId, Arrays.asList(group), 1);
-		db.addGroupMessage(txn, message, false);
+		db.setGroups(txn, contactId, Arrays.asList(group), 1);
+		db.addMessage(txn, message, false);
 		db.addStatus(txn, contactId, messageId, false);
 
 		// The message is sendable, but too large to send
-		assertTrue(db.hasSendableMessages(txn, contactId));
+		assertTrue(db.containsSendableMessages(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));
+		assertTrue(db.containsSendableMessages(txn, contactId));
 		it = db.getSendableMessages(txn, contactId, size).iterator();
 		assertTrue(it.hasNext());
 		assertEquals(messageId, it.next());
@@ -352,28 +263,28 @@ public class H2DatabaseTest extends BriarTestCase {
 	}
 
 	@Test
-	public void testSendableGroupMessagesMustBeVisible() throws Exception {
+	public void testSendableMessagesMustBeVisible() throws Exception {
 		Database<Connection> db = open(false);
 		Connection txn = db.startTransaction();
 
 		// Add a contact, subscribe to a group and store a message
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addSubscription(txn, group);
-		db.setSubscriptions(txn, contactId, Arrays.asList(group), 1);
-		db.addGroupMessage(txn, message, false);
+		db.addGroup(txn, group);
+		db.setGroups(txn, contactId, Arrays.asList(group), 1);
+		db.addMessage(txn, message, false);
 		db.addStatus(txn, contactId, messageId, false);
 
 		// The subscription is not visible to the contact, so the message
 		// should not be sendable
-		assertFalse(db.hasSendableMessages(txn, contactId));
+		assertFalse(db.containsSendableMessages(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));
+		assertTrue(db.containsSendableMessages(txn, contactId));
 		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
 		assertTrue(it.hasNext());
 		assertEquals(messageId, it.next());
@@ -389,6 +300,7 @@ public class H2DatabaseTest extends BriarTestCase {
 		Connection txn = db.startTransaction();
 
 		// Add a contact and some messages to ack
+		MessageId messageId1 = new MessageId(TestUtils.getRandomId());
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
 		db.addMessageToAck(txn, contactId, messageId);
@@ -443,10 +355,10 @@ public class H2DatabaseTest extends BriarTestCase {
 		// Add a contact, subscribe to a group and store a message
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addSubscription(txn, group);
+		db.addGroup(txn, group);
 		db.addVisibility(txn, contactId, groupId);
-		db.setSubscriptions(txn, contactId, Arrays.asList(group), 1);
-		db.addGroupMessage(txn, message, false);
+		db.setGroups(txn, contactId, Arrays.asList(group), 1);
+		db.addMessage(txn, message, false);
 		db.addStatus(txn, contactId, messageId, false);
 
 		// Retrieve the message from the database and mark it as sent
@@ -463,7 +375,7 @@ public class H2DatabaseTest extends BriarTestCase {
 		assertFalse(it.hasNext());
 
 		// Pretend that the message was acked
-		db.removeOutstandingMessages(txn, contactId, Arrays.asList(messageId));
+		db.setStatusSeenIfVisible(txn, contactId, messageId);
 
 		// The message still should not be sendable
 		it = db.getSendableMessages(txn, contactId, ONE_MEGABYTE).iterator();
@@ -482,9 +394,9 @@ public class H2DatabaseTest extends BriarTestCase {
 		Connection txn = db.startTransaction();
 
 		// Subscribe to a group and store two messages
-		db.addSubscription(txn, group);
-		db.addGroupMessage(txn, message, false);
-		db.addGroupMessage(txn, message1, false);
+		db.addGroup(txn, group);
+		db.addMessage(txn, message, false);
+		db.addMessage(txn, message1, false);
 
 		// Allowing enough capacity for one message should return the older one
 		Iterator<MessageId> it = db.getOldMessages(txn, size).iterator();
@@ -507,7 +419,7 @@ public class H2DatabaseTest extends BriarTestCase {
 	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, group, author,
+		Message message = new TestMessage(messageId, null, group, author,
 				contentType, subject, timestamp, largeBody);
 		Database<Connection> db = open(false);
 
@@ -521,8 +433,8 @@ public class H2DatabaseTest extends BriarTestCase {
 
 		// Storing a message should reduce the free space
 		Connection txn = db.startTransaction();
-		db.addSubscription(txn, group);
-		db.addGroupMessage(txn, message1, false);
+		db.addGroup(txn, group);
+		db.addMessage(txn, message, false);
 		db.commitTransaction(txn);
 		assertTrue(db.getFreeSpace() < free);
 
@@ -741,9 +653,9 @@ public class H2DatabaseTest extends BriarTestCase {
 		// Add a contact and subscribe to a group
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addSubscription(txn, group);
+		db.addGroup(txn, group);
 		db.addVisibility(txn, contactId, groupId);
-		db.setSubscriptions(txn, contactId, Arrays.asList(group), 1);
+		db.setGroups(txn, contactId, Arrays.asList(group), 1);
 
 		// The message is not in the database
 		assertNull(db.getRawMessageIfSendable(txn, contactId, messageId));
@@ -760,10 +672,10 @@ public class H2DatabaseTest extends BriarTestCase {
 		// Add a contact, subscribe to a group and store a message
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addSubscription(txn, group);
+		db.addGroup(txn, group);
 		db.addVisibility(txn, contactId, groupId);
-		db.setSubscriptions(txn, contactId, Arrays.asList(group), 1);
-		db.addGroupMessage(txn, message, false);
+		db.setGroups(txn, contactId, Arrays.asList(group), 1);
+		db.addMessage(txn, message, false);
 
 		// Set the status to seen = true
 		db.addStatus(txn, contactId, messageId, true);
@@ -784,11 +696,11 @@ public class H2DatabaseTest extends BriarTestCase {
 		// the message is older than the contact's retention time
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addSubscription(txn, group);
+		db.addGroup(txn, group);
 		db.addVisibility(txn, contactId, groupId);
-		db.setSubscriptions(txn, contactId, Arrays.asList(group), 1);
+		db.setGroups(txn, contactId, Arrays.asList(group), 1);
 		db.setRetentionTime(txn, contactId, timestamp + 1, 1);
-		db.addGroupMessage(txn, message, false);
+		db.addMessage(txn, message, false);
 
 		// Set the status to seen = false
 		db.addStatus(txn, contactId, messageId, false);
@@ -808,10 +720,10 @@ public class H2DatabaseTest extends BriarTestCase {
 		// Add a contact, subscribe to a group and store a message
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addSubscription(txn, group);
+		db.addGroup(txn, group);
 		db.addVisibility(txn, contactId, groupId);
-		db.setSubscriptions(txn, contactId, Arrays.asList(group), 1);
-		db.addGroupMessage(txn, message, false);
+		db.setGroups(txn, contactId, Arrays.asList(group), 1);
+		db.addMessage(txn, message, false);
 
 		// Set the status to seen = false
 		db.addStatus(txn, contactId, messageId, false);
@@ -833,9 +745,9 @@ public class H2DatabaseTest extends BriarTestCase {
 		// Add a contact and subscribe to a group
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addSubscription(txn, group);
+		db.addGroup(txn, group);
 		db.addVisibility(txn, contactId, groupId);
-		db.setSubscriptions(txn, contactId, Arrays.asList(group), 1);
+		db.setGroups(txn, contactId, Arrays.asList(group), 1);
 
 		// The message is not in the database
 		assertFalse(db.setStatusSeenIfVisible(txn, contactId, messageId));
@@ -853,7 +765,7 @@ public class H2DatabaseTest extends BriarTestCase {
 		// Add a contact with a subscription
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.setSubscriptions(txn, contactId, Arrays.asList(group), 1);
+		db.setGroups(txn, contactId, Arrays.asList(group), 1);
 
 		// There's no local subscription for the group
 		assertFalse(db.setStatusSeenIfVisible(txn, contactId, messageId));
@@ -871,9 +783,9 @@ public class H2DatabaseTest extends BriarTestCase {
 		// Add a contact, subscribe to a group and store a message
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addSubscription(txn, group);
+		db.addGroup(txn, group);
 		db.addVisibility(txn, contactId, groupId);
-		db.addGroupMessage(txn, message, false);
+		db.addMessage(txn, message, false);
 		db.addStatus(txn, contactId, messageId, false);
 
 		// There's no contact subscription for the group
@@ -892,9 +804,9 @@ public class H2DatabaseTest extends BriarTestCase {
 		// Add a contact, subscribe to a group and store a message
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addSubscription(txn, group);
-		db.setSubscriptions(txn, contactId, Arrays.asList(group), 1);
-		db.addGroupMessage(txn, message, false);
+		db.addGroup(txn, group);
+		db.setGroups(txn, contactId, Arrays.asList(group), 1);
+		db.addMessage(txn, message, false);
 		db.addStatus(txn, contactId, messageId, false);
 
 		// The subscription is not visible
@@ -913,10 +825,10 @@ public class H2DatabaseTest extends BriarTestCase {
 		// Add a contact, subscribe to a group and store a message
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addSubscription(txn, group);
+		db.addGroup(txn, group);
 		db.addVisibility(txn, contactId, groupId);
-		db.setSubscriptions(txn, contactId, Arrays.asList(group), 1);
-		db.addGroupMessage(txn, message, false);
+		db.setGroups(txn, contactId, Arrays.asList(group), 1);
+		db.addMessage(txn, message, false);
 
 		// The message has already been seen by the contact
 		db.addStatus(txn, contactId, messageId, true);
@@ -936,10 +848,10 @@ public class H2DatabaseTest extends BriarTestCase {
 		// Add a contact, subscribe to a group and store a message
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addSubscription(txn, group);
+		db.addGroup(txn, group);
 		db.addVisibility(txn, contactId, groupId);
-		db.setSubscriptions(txn, contactId, Arrays.asList(group), 1);
-		db.addGroupMessage(txn, message, false);
+		db.setGroups(txn, contactId, Arrays.asList(group), 1);
+		db.addMessage(txn, message, false);
 
 		// The message has not been seen by the contact
 		db.addStatus(txn, contactId, messageId, false);
@@ -958,7 +870,7 @@ public class H2DatabaseTest extends BriarTestCase {
 		// Add a contact and subscribe to a group
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addSubscription(txn, group);
+		db.addGroup(txn, group);
 
 		// The group should not be visible to the contact
 		assertEquals(Collections.emptyList(), db.getVisibility(txn, groupId));
@@ -976,59 +888,59 @@ public class H2DatabaseTest extends BriarTestCase {
 	}
 
 	@Test
-	public void testGetGroupMessageParentWithNoParent() throws Exception {
+	public void testGetParentWithNoParent() throws Exception {
 		Database<Connection> db = open(false);
 		Connection txn = db.startTransaction();
 
 		// Subscribe to a group
-		db.addSubscription(txn, group);
+		db.addGroup(txn, group);
 
 		// A message with no parent should return null
 		MessageId childId = new MessageId(TestUtils.getRandomId());
 		Message child = new TestMessage(childId, null, group, null, contentType,
 				subject, timestamp, raw);
-		db.addGroupMessage(txn, child, false);
+		db.addMessage(txn, child, false);
 		assertTrue(db.containsMessage(txn, childId));
-		assertNull(db.getGroupMessageParent(txn, childId));
+		assertNull(db.getParent(txn, childId));
 
 		db.commitTransaction(txn);
 		db.close();
 	}
 
 	@Test
-	public void testGetGroupMessageParentWithAbsentParent() throws Exception {
+	public void testGetParentWithAbsentParent() throws Exception {
 		Database<Connection> db = open(false);
 		Connection txn = db.startTransaction();
 
 		// Subscribe to a group
-		db.addSubscription(txn, group);
+		db.addGroup(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, group, null,
 				contentType, subject, timestamp, raw);
-		db.addGroupMessage(txn, child, false);
+		db.addMessage(txn, child, false);
 		assertTrue(db.containsMessage(txn, childId));
 		assertFalse(db.containsMessage(txn, parentId));
-		assertNull(db.getGroupMessageParent(txn, childId));
+		assertNull(db.getParent(txn, childId));
 
 		db.commitTransaction(txn);
 		db.close();
 	}
 
 	@Test
-	public void testGetGroupMessageParentWithParentInAnotherGroup()
+	public void testGetParentWithParentInAnotherGroup()
 			throws Exception {
 		GroupId groupId1 = new GroupId(TestUtils.getRandomId());
 		Group group1 = new Group(groupId1, "Another group",
-				new byte[GROUP_SALT_LENGTH]);
+				new byte[GROUP_SALT_LENGTH], false);
 		Database<Connection> db = open(false);
 		Connection txn = db.startTransaction();
 
 		// Subscribe to two groups
-		db.addSubscription(txn, group);
-		db.addSubscription(txn, group1);
+		db.addGroup(txn, group);
+		db.addGroup(txn, group1);
 
 		// A message with a parent in another group should return null
 		MessageId childId = new MessageId(TestUtils.getRandomId());
@@ -1037,48 +949,24 @@ public class H2DatabaseTest extends BriarTestCase {
 				contentType, subject, timestamp, raw);
 		Message parent = new TestMessage(parentId, null, group1, null,
 				contentType, subject, timestamp, raw);
-		db.addGroupMessage(txn, child, false);
-		db.addGroupMessage(txn, parent, false);
+		db.addMessage(txn, child, false);
+		db.addMessage(txn, parent, false);
 		assertTrue(db.containsMessage(txn, childId));
 		assertTrue(db.containsMessage(txn, parentId));
-		assertNull(db.getGroupMessageParent(txn, childId));
+		assertNull(db.getParent(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
-		db.addLocalAuthor(txn, localAuthor);
-		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addSubscription(txn, group);
-
-		// A message with a private parent should return null
-		MessageId childId = new MessageId(TestUtils.getRandomId());
-		Message child = new TestMessage(childId, messageId1, group, null,
-				contentType, subject, timestamp, raw);
-		db.addGroupMessage(txn, child, false);
-		db.addPrivateMessage(txn, privateMessage, contactId, false);
-		assertTrue(db.containsMessage(txn, childId));
-		assertTrue(db.containsMessage(txn, messageId1));
-		assertNull(db.getGroupMessageParent(txn, childId));
-
-		db.commitTransaction(txn);
-		db.close();
-	}
-
-	@Test
-	public void testGetGroupMessageParentWithParentInSameGroup()
+	public void testGetParentWithParentInSameGroup()
 			throws Exception {
 		Database<Connection> db = open(false);
 		Connection txn = db.startTransaction();
 
 		// Subscribe to a group
-		db.addSubscription(txn, group);
+		db.addGroup(txn, group);
 
 		// A message with a parent in the same group should return the parent
 		MessageId childId = new MessageId(TestUtils.getRandomId());
@@ -1087,11 +975,11 @@ public class H2DatabaseTest extends BriarTestCase {
 				contentType, subject, timestamp, raw);
 		Message parent = new TestMessage(parentId, null, group, null,
 				contentType, subject, timestamp, raw);
-		db.addGroupMessage(txn, child, false);
-		db.addGroupMessage(txn, parent, false);
+		db.addMessage(txn, child, false);
+		db.addMessage(txn, parent, false);
 		assertTrue(db.containsMessage(txn, childId));
 		assertTrue(db.containsMessage(txn, parentId));
-		assertEquals(parentId, db.getGroupMessageParent(txn, childId));
+		assertEquals(parentId, db.getParent(txn, childId));
 
 		db.commitTransaction(txn);
 		db.close();
@@ -1105,16 +993,17 @@ public class H2DatabaseTest extends BriarTestCase {
 		// Add a contact and subscribe to a group
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		db.addSubscription(txn, group);
+		db.addGroup(txn, group);
 
 		// Store a couple of messages
 		int bodyLength = raw.length - 20;
-		Message message1 = new TestMessage(messageId, null, group, null,
+		Message message = new TestMessage(messageId, null, group, null,
 				contentType, subject, timestamp, raw, 5, bodyLength);
-		Message privateMessage1 = new TestMessage(messageId1, null, null,
-				null, contentType, subject, timestamp, raw, 10, bodyLength);
-		db.addGroupMessage(txn, message1, false);
-		db.addPrivateMessage(txn, privateMessage1, contactId, false);
+		MessageId messageId1 = new MessageId(TestUtils.getRandomId());
+		Message message1 = new TestMessage(messageId1, null, group, null,
+				contentType, subject, timestamp, raw, 10, bodyLength);
+		db.addMessage(txn, message, false);
+		db.addMessage(txn, message1, false);
 
 		// Calculate the expected message bodies
 		byte[] expectedBody = new byte[bodyLength];
@@ -1144,27 +1033,27 @@ public class H2DatabaseTest extends BriarTestCase {
 		Connection txn = db.startTransaction();
 
 		// Subscribe to a group
-		db.addSubscription(txn, group);
+		db.addGroup(txn, group);
 
 		// Store a couple of messages
-		db.addGroupMessage(txn, message, false);
+		db.addMessage(txn, message, false);
 		MessageId messageId1 = new MessageId(TestUtils.getRandomId());
 		MessageId parentId = new MessageId(TestUtils.getRandomId());
 		long timestamp1 = System.currentTimeMillis();
 		Message message1 = new TestMessage(messageId1, parentId, group, author,
 				contentType, subject, timestamp1, raw);
-		db.addGroupMessage(txn, message1, false);
+		db.addMessage(txn, message1, false);
 		// Mark one of the messages read
 		assertFalse(db.setReadFlag(txn, messageId, true));
 
 		// Retrieve the message headers
-		Collection<GroupMessageHeader> headers =
-				db.getGroupMessageHeaders(txn, groupId);
-		Iterator<GroupMessageHeader> it = headers.iterator();
+		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());
-		GroupMessageHeader header = it.next();
+		MessageHeader header = it.next();
 		if(messageId.equals(header.getId())) {
 			assertHeadersMatch(message, header);
 			assertTrue(header.isRead());
@@ -1199,7 +1088,7 @@ public class H2DatabaseTest extends BriarTestCase {
 		db.close();
 	}
 
-	private void assertHeadersMatch(Message m, GroupMessageHeader h) {
+	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());
@@ -1216,8 +1105,8 @@ public class H2DatabaseTest extends BriarTestCase {
 		Connection txn = db.startTransaction();
 
 		// Subscribe to a group and store a message
-		db.addSubscription(txn, group);
-		db.addGroupMessage(txn, message, false);
+		db.addGroup(txn, group);
+		db.addMessage(txn, message, false);
 
 		// The message should be unread by default
 		assertFalse(db.getReadFlag(txn, messageId));
@@ -1230,7 +1119,7 @@ public class H2DatabaseTest extends BriarTestCase {
 		assertTrue(db.setReadFlag(txn, messageId, false));
 		assertFalse(db.setReadFlag(txn, messageId, false));
 		// Unsubscribe from the group
-		db.removeSubscription(txn, groupId);
+		db.removeGroup(txn, groupId);
 
 		db.commitTransaction(txn);
 		db.close();
@@ -1242,24 +1131,24 @@ public class H2DatabaseTest extends BriarTestCase {
 		Connection txn = db.startTransaction();
 
 		// Subscribe to a couple of groups
-		db.addSubscription(txn, group);
+		db.addGroup(txn, group);
 		GroupId groupId1 = new GroupId(TestUtils.getRandomId());
 		Group group1 = new Group(groupId1, "Another group",
-				new byte[GROUP_SALT_LENGTH]);
-		db.addSubscription(txn, group1);
+				new byte[GROUP_SALT_LENGTH], false);
+		db.addGroup(txn, group1);
 
 		// Store two messages in the first group
-		db.addGroupMessage(txn, message, false);
+		db.addMessage(txn, message, false);
 		MessageId messageId1 = new MessageId(TestUtils.getRandomId());
 		Message message1 = new TestMessage(messageId1, null, group, author,
 				contentType, subject, timestamp, raw);
-		db.addGroupMessage(txn, message1, false);
+		db.addMessage(txn, message1, false);
 
 		// Store one message in the second group
 		MessageId messageId2 = new MessageId(TestUtils.getRandomId());
 		Message message2 = new TestMessage(messageId2, null, group1, author,
 				contentType, subject, timestamp, raw);
-		db.addGroupMessage(txn, message2, false);
+		db.addMessage(txn, message2, false);
 
 		// Mark one of the messages in the first group read
 		assertFalse(db.setReadFlag(txn, messageId, true));
@@ -1299,7 +1188,7 @@ public class H2DatabaseTest extends BriarTestCase {
 		for(int i = 0; i < 100; i++) {
 			GroupId id = new GroupId(TestUtils.getRandomId());
 			String name = "Group " + i;
-			groups.add(new Group(id, name, new byte[GROUP_SALT_LENGTH]));
+			groups.add(new Group(id, name, new byte[GROUP_SALT_LENGTH], false));
 		}
 
 		Database<Connection> db = open(false);
@@ -1308,7 +1197,7 @@ public class H2DatabaseTest extends BriarTestCase {
 		// Add a contact and subscribe to the groups
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
-		for(Group g : groups) db.addSubscription(txn, g);
+		for(Group g : groups) db.addGroup(txn, g);
 
 		// Make the groups visible to the contact
 		Collections.shuffle(groups);
@@ -1319,7 +1208,7 @@ public class H2DatabaseTest extends BriarTestCase {
 		for(Group g : groups) {
 			if(Math.random() < 0.5)
 				db.removeVisibility(txn, contactId, g.getId());
-			db.removeSubscription(txn, g.getId());
+			db.removeGroup(txn, g.getId());
 		}
 
 		db.commitTransaction(txn);
@@ -1644,11 +1533,11 @@ public class H2DatabaseTest extends BriarTestCase {
 		db.addLocalAuthor(txn, localAuthor);
 		assertEquals(contactId, db.addContact(txn, author, localAuthorId));
 		assertEquals(contactId1, db.addContact(txn, author1, localAuthorId));
-		db.setSubscriptions(txn, contactId, Arrays.asList(group), 1);
-		db.setSubscriptions(txn, contactId1, Arrays.asList(group), 1);
+		db.setGroups(txn, contactId, Arrays.asList(group), 1);
+		db.setGroups(txn, contactId1, Arrays.asList(group), 1);
 
 		// The group should be available, not subscribed, not visible to all
-		assertEquals(Collections.emptyList(), db.getSubscriptions(txn));
+		assertEquals(Collections.emptyList(), db.getGroups(txn));
 		Iterator<GroupStatus> it = db.getAvailableGroups(txn).iterator();
 		assertTrue(it.hasNext());
 		GroupStatus status = it.next();
@@ -1659,8 +1548,8 @@ public class H2DatabaseTest extends BriarTestCase {
 
 		// Subscribe to the group - it should be available, subscribed,
 		// not visible to all
-		db.addSubscription(txn, group);
-		assertEquals(Arrays.asList(group), db.getSubscriptions(txn));
+		db.addGroup(txn, group);
+		assertEquals(Arrays.asList(group), db.getGroups(txn));
 		it = db.getAvailableGroups(txn).iterator();
 		assertTrue(it.hasNext());
 		status = it.next();
@@ -1671,8 +1560,8 @@ public class H2DatabaseTest extends BriarTestCase {
 
 		// The first contact unsubscribes - the group should be available,
 		// subscribed, not visible to all
-		db.setSubscriptions(txn, contactId, Collections.<Group>emptyList(), 2);
-		assertEquals(Arrays.asList(group), db.getSubscriptions(txn));
+		db.setGroups(txn, contactId, Collections.<Group>emptyList(), 2);
+		assertEquals(Arrays.asList(group), db.getGroups(txn));
 		it = db.getAvailableGroups(txn).iterator();
 		assertTrue(it.hasNext());
 		status = it.next();
@@ -1684,7 +1573,7 @@ public class H2DatabaseTest extends BriarTestCase {
 		// Make the group visible to all contacts - it should be available,
 		// subscribed, visible to all
 		db.setVisibleToAll(txn, groupId, true);
-		assertEquals(Arrays.asList(group), db.getSubscriptions(txn));
+		assertEquals(Arrays.asList(group), db.getGroups(txn));
 		it = db.getAvailableGroups(txn).iterator();
 		assertTrue(it.hasNext());
 		status = it.next();
@@ -1695,8 +1584,8 @@ public class H2DatabaseTest extends BriarTestCase {
 
 		// Unsubscribe from the group - it should be available, not subscribed,
 		// not visible to all
-		db.removeSubscription(txn, groupId);
-		assertEquals(Collections.emptyList(), db.getSubscriptions(txn));
+		db.removeGroup(txn, groupId);
+		assertEquals(Collections.emptyList(), db.getGroups(txn));
 		it = db.getAvailableGroups(txn).iterator();
 		assertTrue(it.hasNext());
 		status = it.next();
@@ -1707,8 +1596,8 @@ public class H2DatabaseTest extends BriarTestCase {
 
 		// The second contact unsubscribes - the group should no longer be
 		// available
-		db.setSubscriptions(txn, contactId1, Collections.<Group>emptyList(), 2);
-		assertEquals(Collections.emptyList(), db.getSubscriptions(txn));
+		db.setGroups(txn, contactId1, Collections.<Group>emptyList(), 2);
+		assertEquals(Collections.emptyList(), db.getGroups(txn));
 		assertEquals(Collections.emptyList(), db.getAvailableGroups(txn));
 
 		db.commitTransaction(txn);
diff --git a/briar-tests/src/net/sf/briar/messaging/ConstantsTest.java b/briar-tests/src/net/sf/briar/messaging/ConstantsTest.java
index 5534c73d95..b5065a898f 100644
--- a/briar-tests/src/net/sf/briar/messaging/ConstantsTest.java
+++ b/briar-tests/src/net/sf/briar/messaging/ConstantsTest.java
@@ -121,7 +121,7 @@ public class ConstantsTest extends BriarTestCase {
 		MessageId parent = new MessageId(TestUtils.getRandomId());
 		// Create a maximum-length group
 		String groupName = TestUtils.createRandomString(MAX_GROUP_NAME_LENGTH);
-		Group group = groupFactory.createGroup(groupName);
+		Group group = groupFactory.createGroup(groupName, false);
 		// Create a maximum-length author
 		String authorName =
 				TestUtils.createRandomString(MAX_AUTHOR_NAME_LENGTH);
@@ -177,13 +177,13 @@ public class ConstantsTest extends BriarTestCase {
 	@Test
 	public void testGroupsFitIntoSubscriptionUpdate() throws Exception {
 		// Create the maximum number of maximum-length groups
-		Collection<Group> subs = new ArrayList<Group>();
+		Collection<Group> groups = new ArrayList<Group>();
 		for(int i = 0; i < MAX_SUBSCRIPTIONS; i++) {
 			String name = TestUtils.createRandomString(MAX_GROUP_NAME_LENGTH);
-			subs.add(groupFactory.createGroup(name));
+			groups.add(groupFactory.createGroup(name, false));
 		}
 		// Create a maximum-length subscription update
-		SubscriptionUpdate u = new SubscriptionUpdate(subs, Long.MAX_VALUE);
+		SubscriptionUpdate u = new SubscriptionUpdate(groups, Long.MAX_VALUE);
 		// Serialise the update
 		ByteArrayOutputStream out = new ByteArrayOutputStream();
 		PacketWriter writer = packetWriterFactory.createPacketWriter(out, true);
diff --git a/briar-tests/src/net/sf/briar/messaging/simplex/SimplexMessagingIntegrationTest.java b/briar-tests/src/net/sf/briar/messaging/simplex/SimplexMessagingIntegrationTest.java
index 5d0d325fb8..2511f6a594 100644
--- a/briar-tests/src/net/sf/briar/messaging/simplex/SimplexMessagingIntegrationTest.java
+++ b/briar-tests/src/net/sf/briar/messaging/simplex/SimplexMessagingIntegrationTest.java
@@ -1,6 +1,7 @@
 package net.sf.briar.messaging.simplex;
 
 import static net.sf.briar.api.AuthorConstants.MAX_PUBLIC_KEY_LENGTH;
+import static net.sf.briar.api.messaging.MessagingConstants.GROUP_SALT_LENGTH;
 import static net.sf.briar.api.transport.TransportConstants.TAG_LENGTH;
 
 import java.io.ByteArrayInputStream;
@@ -21,7 +22,9 @@ 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.PrivateMessageAddedEvent;
+import net.sf.briar.api.db.event.MessageAddedEvent;
+import net.sf.briar.api.messaging.Group;
+import net.sf.briar.api.messaging.GroupId;
 import net.sf.briar.api.messaging.Message;
 import net.sf.briar.api.messaging.MessageFactory;
 import net.sf.briar.api.messaging.MessageVerifier;
@@ -57,6 +60,7 @@ public class SimplexMessagingIntegrationTest extends BriarTestCase {
 	private final File testDir = TestUtils.getTestDirectory();
 	private final File aliceDir = new File(testDir, "alice");
 	private final File bobDir = new File(testDir, "bob");
+	private final Group group;
 	private final TransportId transportId;
 	private final byte[] initialSecret;
 	private final long epoch;
@@ -64,6 +68,8 @@ public class SimplexMessagingIntegrationTest extends BriarTestCase {
 	private Injector alice, bob;
 
 	public SimplexMessagingIntegrationTest() throws Exception {
+		GroupId groupId = new GroupId(TestUtils.getRandomId());
+		group = new Group(groupId, "Group", new byte[GROUP_SALT_LENGTH], true);
 		transportId = new TransportId(TestUtils.getRandomId());
 		// Create matching secrets for Alice and Bob
 		initialSecret = new byte[32];
@@ -116,6 +122,9 @@ public class SimplexMessagingIntegrationTest extends BriarTestCase {
 		Author bobAuthor = new Author(bobId, "Bob",
 				new byte[MAX_PUBLIC_KEY_LENGTH]);
 		ContactId contactId = db.addContact(bobAuthor, aliceId);
+		// Add the inbox group
+		db.addGroup(group);
+		db.setInboxGroup(contactId, group);
 		// Add the transport and the endpoint
 		db.addTransport(transportId, LATENCY);
 		Endpoint ep = new Endpoint(contactId, transportId, epoch, true);
@@ -126,9 +135,9 @@ public class SimplexMessagingIntegrationTest extends BriarTestCase {
 		long timestamp = System.currentTimeMillis();
 		byte[] body = "Hi Bob!".getBytes("UTF-8");
 		MessageFactory messageFactory = alice.getInstance(MessageFactory.class);
-		Message message = messageFactory.createPrivateMessage(null, contentType,
-				timestamp, body);
-		db.addLocalPrivateMessage(message, contactId);
+		Message message = messageFactory.createAnonymousMessage(null, group,
+				contentType, timestamp, body);
+		db.addLocalMessage(message);
 		// Create an outgoing simplex connection
 		ByteArrayOutputStream out = new ByteArrayOutputStream();
 		ConnectionRegistry connRegistry =
@@ -172,6 +181,9 @@ public class SimplexMessagingIntegrationTest extends BriarTestCase {
 		Author aliceAuthor = new Author(aliceId, "Alice",
 				new byte[MAX_PUBLIC_KEY_LENGTH]);
 		ContactId contactId = db.addContact(aliceAuthor, bobId);
+		// Add the inbox group
+		db.addGroup(group);
+		db.setInboxGroup(contactId, group);
 		// Add the transport and the endpoint
 		db.addTransport(transportId, LATENCY);
 		Endpoint ep = new Endpoint(contactId, transportId, epoch, false);
@@ -227,7 +239,7 @@ public class SimplexMessagingIntegrationTest extends BriarTestCase {
 		private boolean messageAdded = false;
 
 		public void eventOccurred(DatabaseEvent e) {
-			if(e instanceof PrivateMessageAddedEvent) messageAdded = true;
+			if(e instanceof MessageAddedEvent) messageAdded = true;
 		}
 	}
 }
-- 
GitLab