From 23ab23a93183274c64e622ac66e91adda7d68ea2 Mon Sep 17 00:00:00 2001
From: akwizgran <michael@briarproject.org>
Date: Thu, 14 Mar 2013 20:58:20 +0000
Subject: [PATCH] Attached data to DB events to avoid DB lookups; refactored UI
 code.

Fields in Android UI objects that are accessed from background threads
must be declared volatile. UI objects use data attached to DB events to
avoid DB lookups, which complicates the UI code but should improve
performance.
---
 briar-android/AndroidManifest.xml             |   4 +-
 .../android/contact/ContactComparator.java    |  13 --
 .../android/contact/ContactListActivity.java  |  72 +++++---
 .../briar/android/groups/GroupActivity.java   | 168 ++++++++++--------
 .../sf/briar/android/groups/GroupAdapter.java |   8 +-
 .../sf/briar/android/groups/GroupItem.java    |  50 ------
 .../android/groups/GroupListActivity.java     | 149 ++++++++++++----
 .../briar/android/groups/GroupListItem.java   |  51 +++++-
 .../groups/ReadGroupMessageActivity.java      |  19 +-
 .../groups/WriteGroupMessageActivity.java     |  51 +++---
 .../invitation/AddContactActivity.java        |   9 +-
 .../messages/ConversationActivity.java        |  84 +++++----
 .../messages/ConversationComparator.java      |  16 --
 .../messages/ConversationListActivity.java    | 151 +++++++++++++---
 .../messages/ConversationListItem.java        |  39 +++-
 .../messages/ReadPrivateMessageActivity.java  |  13 +-
 .../messages/WritePrivateMessageActivity.java |  45 +++--
 .../sf/briar/api/db/DatabaseComponent.java    |   6 +
 .../sf/briar/api/db/GroupMessageHeader.java   |  21 ++-
 .../sf/briar/api/db/PrivateMessageHeader.java |   7 +
 .../api/db/event/GroupMessageAddedEvent.java  |  23 +++
 .../briar/api/db/event/MessageAddedEvent.java |   9 -
 .../api/db/event/MessageReceivedEvent.java    |  11 ++
 .../db/event/PrivateMessageAddedEvent.java    |  33 ++++
 .../api/db/event/SubscriptionAddedEvent.java  |  12 +-
 briar-core/src/net/sf/briar/db/Database.java  |  12 ++
 .../sf/briar/db/DatabaseComponentImpl.java    |  60 ++++++-
 .../src/net/sf/briar/db/JdbcDatabase.java     |  65 ++++++-
 .../messaging/duplex/DuplexConnection.java    |  18 +-
 .../sf/briar/db/DatabaseComponentTest.java    |  68 ++++++-
 .../SimplexMessagingIntegrationTest.java      |  10 +-
 31 files changed, 891 insertions(+), 406 deletions(-)
 delete mode 100644 briar-android/src/net/sf/briar/android/contact/ContactComparator.java
 delete mode 100644 briar-android/src/net/sf/briar/android/groups/GroupItem.java
 delete mode 100644 briar-android/src/net/sf/briar/android/messages/ConversationComparator.java
 create mode 100644 briar-api/src/net/sf/briar/api/db/event/GroupMessageAddedEvent.java
 delete mode 100644 briar-api/src/net/sf/briar/api/db/event/MessageAddedEvent.java
 create mode 100644 briar-api/src/net/sf/briar/api/db/event/PrivateMessageAddedEvent.java

diff --git a/briar-android/AndroidManifest.xml b/briar-android/AndroidManifest.xml
index e402626aff..fc8fcd5289 100644
--- a/briar-android/AndroidManifest.xml
+++ b/briar-android/AndroidManifest.xml
@@ -69,11 +69,11 @@
 			android:label="@string/messages_title" >
 		</activity>
 		<activity
-			android:name="net.sf.briar.android.messages.ReadPrivateMessageActivity"
+			android:name=".android.messages.ReadPrivateMessageActivity"
 			android:label="@string/messages_title" >
 		</activity>
 		<activity
-			android:name="net.sf.briar.android.messages.WritePrivateMessageActivity"
+			android:name=".android.messages.WritePrivateMessageActivity"
 			android:label="@string/compose_message_title" >
 		</activity>
 	</application>
diff --git a/briar-android/src/net/sf/briar/android/contact/ContactComparator.java b/briar-android/src/net/sf/briar/android/contact/ContactComparator.java
deleted file mode 100644
index 8e9207c6fc..0000000000
--- a/briar-android/src/net/sf/briar/android/contact/ContactComparator.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package net.sf.briar.android.contact;
-
-import java.util.Comparator;
-
-class ContactComparator implements Comparator<ContactListItem> {
-
-	static final ContactComparator INSTANCE = new ContactComparator();
-
-	public int compare(ContactListItem a, ContactListItem b) {
-		return String.CASE_INSENSITIVE_ORDER.compare(a.getContactName(),
-				b.getContactName());
-	}
-}
\ No newline at end of file
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 fe5f673e9c..5b01e6bb4f 100644
--- a/briar-android/src/net/sf/briar/android/contact/ContactListActivity.java
+++ b/briar-android/src/net/sf/briar/android/contact/ContactListActivity.java
@@ -6,6 +6,7 @@ import static java.util.logging.Level.INFO;
 import static java.util.logging.Level.WARNING;
 
 import java.util.Collection;
+import java.util.Comparator;
 import java.util.concurrent.Executor;
 import java.util.logging.Logger;
 
@@ -46,12 +47,13 @@ implements OnClickListener, DatabaseListener, ConnectionListener {
 	private final BriarServiceConnection serviceConnection =
 			new BriarServiceConnection();
 
-	@Inject private DatabaseComponent db;
-	@Inject @DatabaseExecutor private Executor dbExecutor;
 	@Inject private ConnectionRegistry connectionRegistry;
-
 	private ContactListAdapter adapter = null;
 
+	// Fields that are accessed from DB threads must be volatile
+	@Inject private volatile DatabaseComponent db;
+	@Inject @DatabaseExecutor private volatile Executor dbExecutor;
+
 	@Override
 	public void onCreate(Bundle state) {
 		super.onCreate(null);
@@ -87,8 +89,11 @@ implements OnClickListener, DatabaseListener, ConnectionListener {
 				serviceConnection, 0);
 
 		// Add some fake contacts to the database in a background thread
-		// FIXME: Remove this
-		final DatabaseComponent db = this.db;
+		insertFakeContacts();
+	}
+
+	// FIXME: Remove this
+	private void insertFakeContacts() {
 		dbExecutor.execute(new Runnable() {
 			public void run() {
 				try {
@@ -117,28 +122,10 @@ implements OnClickListener, DatabaseListener, ConnectionListener {
 	@Override
 	public void onResume() {
 		super.onResume();
-		reloadContactList();
-	}
-
-	@Override
-	public void onDestroy() {
-		super.onDestroy();
-		db.removeListener(this);
-		connectionRegistry.removeListener(this);
-		unbindService(serviceConnection);
-	}
-
-	public void onClick(View view) {
-		startActivity(new Intent(this, AddContactActivity.class));
-	}
-
-	public void eventOccurred(DatabaseEvent e) {
-		if(e instanceof ContactAddedEvent) reloadContactList();
-		else if(e instanceof ContactRemovedEvent) reloadContactList();
+		loadContacts();
 	}
 
-	private void reloadContactList() {
-		final DatabaseComponent db = this.db;
+	private void loadContacts() {
 		dbExecutor.execute(new Runnable() {
 			public void run() {
 				try {
@@ -148,8 +135,8 @@ implements OnClickListener, DatabaseListener, ConnectionListener {
 					Collection<Contact> contacts = db.getContacts();
 					if(LOG.isLoggable(INFO))
 						LOG.info("Loaded " + contacts.size() + " contacts");
-					// Update the contact list
-					updateContactList(contacts);
+					// Display the contacts in the UI
+					displayContacts(contacts);
 				} catch(DbException e) {
 					if(LOG.isLoggable(WARNING))
 						LOG.log(WARNING, e.toString(), e);
@@ -162,7 +149,7 @@ implements OnClickListener, DatabaseListener, ConnectionListener {
 		});
 	}
 
-	private void updateContactList(final Collection<Contact> contacts) {
+	private void displayContacts(final Collection<Contact> contacts) {
 		runOnUiThread(new Runnable() {
 			public void run() {
 				adapter.clear();
@@ -175,6 +162,24 @@ implements OnClickListener, DatabaseListener, ConnectionListener {
 		});
 	}
 
+	@Override
+	public void onDestroy() {
+		super.onDestroy();
+		db.removeListener(this);
+		connectionRegistry.removeListener(this);
+		unbindService(serviceConnection);
+	}
+
+	public void onClick(View view) {
+		startActivity(new Intent(this, AddContactActivity.class));
+	}
+
+	public void eventOccurred(DatabaseEvent e) {
+		// These events should be rare, so just reload the list
+		if(e instanceof ContactAddedEvent) loadContacts();
+		else if(e instanceof ContactRemovedEvent) loadContacts();
+	}
+
 	public void contactConnected(ContactId c) {
 		setConnected(c, true);
 	}
@@ -197,4 +202,15 @@ implements OnClickListener, DatabaseListener, ConnectionListener {
 			}
 		});
 	}
+
+	private static class ContactComparator
+	implements Comparator<ContactListItem> {
+
+		static final ContactComparator INSTANCE = new ContactComparator();
+
+		public int compare(ContactListItem a, ContactListItem b) {
+			return String.CASE_INSENSITIVE_ORDER.compare(a.getContactName(),
+					b.getContactName());
+		}
+	}
 }
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 148e73402b..5873b07ce7 100644
--- a/briar-android/src/net/sf/briar/android/groups/GroupActivity.java
+++ b/briar-android/src/net/sf/briar/android/groups/GroupActivity.java
@@ -6,12 +6,9 @@ import static java.util.logging.Level.INFO;
 import static java.util.logging.Level.WARNING;
 import static net.sf.briar.api.Rating.UNRATED;
 
-import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
+import java.util.HashSet;
+import java.util.Set;
 import java.util.concurrent.Executor;
 import java.util.logging.Logger;
 
@@ -30,13 +27,13 @@ import net.sf.briar.api.db.GroupMessageHeader;
 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.MessageAddedEvent;
+import net.sf.briar.api.db.event.GroupMessageAddedEvent;
 import net.sf.briar.api.db.event.MessageExpiredEvent;
-import net.sf.briar.api.db.event.RatingChangedEvent;
 import net.sf.briar.api.db.event.SubscriptionRemovedEvent;
 import net.sf.briar.api.messaging.Author;
-import net.sf.briar.api.messaging.AuthorId;
 import net.sf.briar.api.messaging.GroupId;
+import net.sf.briar.api.messaging.Message;
+import net.sf.briar.api.messaging.MessageId;
 import android.content.Intent;
 import android.os.Bundle;
 import android.view.View;
@@ -58,14 +55,17 @@ OnClickListener, OnItemClickListener {
 	private final BriarServiceConnection serviceConnection =
 			new BriarServiceConnection();
 
-	@Inject private DatabaseComponent db;
-	@Inject @DatabaseExecutor private Executor dbExecutor;
-
-	private GroupId groupId = null;
+	// The following fields must only be accessed from the UI thread
+	private final Set<MessageId> messageIds = new HashSet<MessageId>();
 	private String groupName = null;
 	private GroupAdapter adapter = null;
 	private ListView list = null;
 
+	// Fields that are accessed from DB threads must be volatile
+	@Inject private volatile DatabaseComponent db;
+	@Inject @DatabaseExecutor private volatile Executor dbExecutor;
+	private volatile GroupId groupId = null;
+
 	@Override
 	public void onCreate(Bundle state) {
 		super.onCreate(null);
@@ -111,32 +111,22 @@ OnClickListener, OnItemClickListener {
 	@Override
 	public void onResume() {
 		super.onResume();
-		reloadMessageHeaders();
+		loadHeaders();
 	}
 
-	private void reloadMessageHeaders() {
-		final DatabaseComponent db = this.db;
-		final GroupId groupId = this.groupId;
+	private void loadHeaders() {
 		dbExecutor.execute(new Runnable() {
 			public void run() {
 				try {
 					// Wait for the service to be bound and started
 					serviceConnection.waitForStartup();
-					// Load the message headers from the database
+					// Load the headers from the database
 					Collection<GroupMessageHeader> headers =
 							db.getMessageHeaders(groupId);
 					if(LOG.isLoggable(INFO))
 						LOG.info("Loaded " + headers.size() + " headers");
-					// Load the ratings for the authors
-					Map<Author, Rating> ratings = new HashMap<Author, Rating>();
-					for(GroupMessageHeader h : headers) {
-						Author a = h.getAuthor();
-						if(a != null && !ratings.containsKey(a))
-							ratings.put(a, db.getRating(a.getId()));
-					}
-					ratings = Collections.unmodifiableMap(ratings);
-					// Update the conversation
-					updateConversation(headers, ratings);
+					// Display the headers in the UI
+					displayHeaders(headers);
 				} catch(NoSuchSubscriptionException e) {
 					if(LOG.isLoggable(INFO)) LOG.info("Subscription removed");
 					finishOnUiThread();
@@ -152,29 +142,46 @@ OnClickListener, OnItemClickListener {
 		});
 	}
 
-	private void updateConversation(
-			final Collection<GroupMessageHeader> headers,
-			final Map<Author, Rating> ratings) {
+	private void displayHeaders(final Collection<GroupMessageHeader> headers) {
 		runOnUiThread(new Runnable() {
 			public void run() {
-				List<GroupMessageHeader> sort =
-						new ArrayList<GroupMessageHeader>(headers);
-				Collections.sort(sort, AscendingHeaderComparator.INSTANCE);
-				int firstUnread = -1;
+				messageIds.clear();
 				adapter.clear();
-				for(GroupMessageHeader h : sort) {
-					if(firstUnread == -1 && !h.isRead())
-						firstUnread = adapter.getCount();
-					Author a = h.getAuthor();
-					if(a == null) adapter.add(new GroupItem(h, UNRATED));
-					else adapter.add(new GroupItem(h, ratings.get(a)));
+				for(GroupMessageHeader h : headers) {
+					messageIds.add(h.getId());
+					adapter.add(h);
 				}
-				if(firstUnread == -1) list.setSelection(adapter.getCount() - 1);
-				else list.setSelection(firstUnread);
+				adapter.sort(AscendingHeaderComparator.INSTANCE);
+				selectFirstUnread();
 			}
 		});
 	}
 
+	private void selectFirstUnread() {
+		int firstUnread = -1, count = adapter.getCount();
+		for(int i = 0; i < count; i++) {
+			if(!adapter.getItem(i).isRead()) {
+				firstUnread = i;
+				break;
+			}
+		}
+		if(firstUnread == -1) list.setSelection(count - 1);
+		else list.setSelection(firstUnread);
+	}
+
+	@Override
+	public void onActivityResult(int request, int result, Intent data) {
+		if(result == ReadGroupMessageActivity.RESULT_PREV) {
+			int position = request - 1;
+			if(position >= 0 && position < adapter.getCount())
+				showMessage(position);
+		} else if(result == ReadGroupMessageActivity.RESULT_NEXT) {
+			int position = request + 1;
+			if(position >= 0 && position < adapter.getCount())
+				showMessage(position);
+		}
+	}
+
 	@Override
 	public void onDestroy() {
 		super.onDestroy();
@@ -183,38 +190,59 @@ OnClickListener, OnItemClickListener {
 	}
 
 	public void eventOccurred(DatabaseEvent e) {
-		if(e instanceof MessageAddedEvent) {
-			if(LOG.isLoggable(INFO)) LOG.info("Message added, reloading");
-			reloadMessageHeaders();
+		if(e instanceof GroupMessageAddedEvent) {
+			GroupMessageAddedEvent g = (GroupMessageAddedEvent) e;
+			Message m = g.getMessage();
+			if(m.getGroup().getId().equals(groupId))
+				loadRatingOrAddToGroup(m, g.isIncoming());
 		} else if(e instanceof MessageExpiredEvent) {
 			if(LOG.isLoggable(INFO)) LOG.info("Message removed, reloading");
-			reloadMessageHeaders();
-		} else if(e instanceof RatingChangedEvent) {
-			RatingChangedEvent r = (RatingChangedEvent) e;
-			updateRating(r.getAuthorId(), r.getRating());
+			loadHeaders(); // FIXME: Don't reload unnecessarily
 		} else if(e instanceof SubscriptionRemovedEvent) {
-			SubscriptionRemovedEvent s = (SubscriptionRemovedEvent) e;
-			if(s.getGroupId().equals(groupId)) {
+			if(((SubscriptionRemovedEvent) e).getGroupId().equals(groupId)) {
 				if(LOG.isLoggable(INFO)) LOG.info("Subscription removed");
 				finishOnUiThread();
 			}
 		}
 	}
 
-	private void updateRating(final AuthorId a, final Rating r) {
+	private void loadRatingOrAddToGroup(Message m, boolean incoming) {
+		// FIXME: Cache ratings to avoid hitting the DB
+		if(m.getAuthor() == null) addToGroup(m, UNRATED, incoming);
+		else loadRating(m, incoming);
+	}
+
+	private void addToGroup(final Message m, final Rating r,
+			final boolean incoming) {
 		runOnUiThread(new Runnable() {
 			public void run() {
-				boolean affected = false;
-				int count = adapter.getCount();
-				for(int i = 0; i < count; i++) {
-					GroupItem item = adapter.getItem(i);
-					Author author = item.getAuthor();
-					if(author != null && author.getId().equals(a)) {
-						item.setRating(r);
-						affected = true;
-					}
+				if(messageIds.add(m.getId())) {
+					adapter.add(new GroupMessageHeader(m, !incoming, false, r));
+					adapter.sort(AscendingHeaderComparator.INSTANCE);
+					selectFirstUnread();
+				}
+			}
+		});
+	}
+
+	private void loadRating(final Message m, final boolean incoming) {
+		dbExecutor.execute(new Runnable() {
+			public void run() {
+				try {
+					// Wait for the service to be bound and started
+					serviceConnection.waitForStartup();
+					// Load the rating from the database
+					Rating r = db.getRating(m.getAuthor().getId());
+					// Display the message
+					addToGroup(m, r, incoming);
+				} catch(DbException e) {
+					if(LOG.isLoggable(WARNING))
+						LOG.log(WARNING, e.toString(), e);
+				} catch(InterruptedException e) {
+					if(LOG.isLoggable(INFO))
+						LOG.info("Interrupted while waiting for service");
+					Thread.currentThread().interrupt();
 				}
-				if(affected) list.invalidate();
 			}
 		});
 	}
@@ -222,7 +250,6 @@ OnClickListener, OnItemClickListener {
 	public void onClick(View view) {
 		Intent i = new Intent(this, WriteGroupMessageActivity.class);
 		i.putExtra("net.sf.briar.GROUP_ID", groupId.getBytes());
-		i.putExtra("net.sf.briar.GROUP_NAME", groupName);
 		startActivity(i);
 	}
 
@@ -232,7 +259,7 @@ OnClickListener, OnItemClickListener {
 	}
 
 	private void showMessage(int position) {
-		GroupItem item = adapter.getItem(position);
+		GroupMessageHeader item = adapter.getItem(position);
 		Intent i = new Intent(this, ReadGroupMessageActivity.class);
 		i.putExtra("net.sf.briar.GROUP_ID", groupId.getBytes());
 		i.putExtra("net.sf.briar.GROUP_NAME", groupName);
@@ -252,17 +279,4 @@ OnClickListener, OnItemClickListener {
 		i.putExtra("net.sf.briar.LAST", position == adapter.getCount() - 1);
 		startActivityForResult(i, position);
 	}
-
-	@Override
-	public void onActivityResult(int request, int result, Intent data) {
-		if(result == ReadGroupMessageActivity.RESULT_PREV) {
-			int position = request - 1;
-			if(position >= 0 && position < adapter.getCount())
-				showMessage(position);
-		} else if(result == ReadGroupMessageActivity.RESULT_NEXT) {
-			int position = request + 1;
-			if(position >= 0 && position < adapter.getCount())
-				showMessage(position);
-		}
-	}
 }
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 463785884c..c908c0332a 100644
--- a/briar-android/src/net/sf/briar/android/groups/GroupAdapter.java
+++ b/briar-android/src/net/sf/briar/android/groups/GroupAdapter.java
@@ -15,6 +15,7 @@ import net.sf.briar.R;
 import net.sf.briar.android.widgets.CommonLayoutParams;
 import net.sf.briar.android.widgets.HorizontalSpace;
 import net.sf.briar.api.Rating;
+import net.sf.briar.api.db.GroupMessageHeader;
 import net.sf.briar.api.messaging.Author;
 import android.content.Context;
 import android.content.res.Resources;
@@ -26,21 +27,20 @@ import android.widget.ImageView;
 import android.widget.LinearLayout;
 import android.widget.TextView;
 
-class GroupAdapter extends ArrayAdapter<GroupItem> {
+class GroupAdapter extends ArrayAdapter<GroupMessageHeader> {
 
 	GroupAdapter(Context ctx) {
 		super(ctx, android.R.layout.simple_expandable_list_item_1,
-				new ArrayList<GroupItem>());
+				new ArrayList<GroupMessageHeader>());
 	}
 
 	@Override
 	public View getView(int position, View convertView, ViewGroup parent) {
-		GroupItem item = getItem(position);
+		GroupMessageHeader item = getItem(position);
 		Context ctx = getContext();
 		// FIXME: Use a RelativeLayout
 		LinearLayout layout = new LinearLayout(ctx);
 		layout.setOrientation(HORIZONTAL);
-		// layout.setGravity(CENTER_VERTICAL);
 		if(!item.isRead()) {
 			Resources res = ctx.getResources();
 			layout.setBackgroundColor(res.getColor(R.color.unread_background));
diff --git a/briar-android/src/net/sf/briar/android/groups/GroupItem.java b/briar-android/src/net/sf/briar/android/groups/GroupItem.java
deleted file mode 100644
index 3e6cb18213..0000000000
--- a/briar-android/src/net/sf/briar/android/groups/GroupItem.java
+++ /dev/null
@@ -1,50 +0,0 @@
-package net.sf.briar.android.groups;
-
-import net.sf.briar.api.Rating;
-import net.sf.briar.api.db.GroupMessageHeader;
-import net.sf.briar.api.messaging.Author;
-import net.sf.briar.api.messaging.MessageId;
-
-// This class is not thread-safe
-class GroupItem {
-
-	private final GroupMessageHeader header;
-	private Rating rating;
-
-	GroupItem(GroupMessageHeader header, Rating rating) {
-		this.header = header;
-		this.rating = rating;
-	}
-
-	MessageId getId() {
-		return header.getId();
-	}
-
-	Author getAuthor() {
-		return header.getAuthor();
-	}
-
-	String getContentType() {
-		return header.getContentType();
-	}
-
-	String getSubject() {
-		return header.getSubject();
-	}
-
-	long getTimestamp() {
-		return header.getTimestamp();
-	}
-
-	boolean isRead() {
-		return header.isRead();
-	}
-
-	Rating getRating() {
-		return rating;
-	}
-
-	void setRating(Rating rating) {
-		this.rating = rating;
-	}
-}
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 ee38bfd8d8..48a977e842 100644
--- a/briar-android/src/net/sf/briar/android/groups/GroupListActivity.java
+++ b/briar-android/src/net/sf/briar/android/groups/GroupListActivity.java
@@ -36,14 +36,14 @@ import net.sf.briar.api.db.GroupMessageHeader;
 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.MessageAddedEvent;
+import net.sf.briar.api.db.event.GroupMessageAddedEvent;
 import net.sf.briar.api.db.event.MessageExpiredEvent;
-import net.sf.briar.api.db.event.SubscriptionAddedEvent;
 import net.sf.briar.api.db.event.SubscriptionRemovedEvent;
 import net.sf.briar.api.messaging.Author;
 import net.sf.briar.api.messaging.AuthorFactory;
 import net.sf.briar.api.messaging.Group;
 import net.sf.briar.api.messaging.GroupFactory;
+import net.sf.briar.api.messaging.GroupId;
 import net.sf.briar.api.messaging.Message;
 import net.sf.briar.api.messaging.MessageFactory;
 import android.content.Intent;
@@ -65,14 +65,16 @@ implements OnClickListener, DatabaseListener {
 	private final BriarServiceConnection serviceConnection =
 			new BriarServiceConnection();
 
-	@Inject private CryptoComponent crypto;
-	@Inject private DatabaseComponent db;
-	@Inject @DatabaseExecutor private Executor dbExecutor;
-	@Inject private AuthorFactory authorFactory;
-	@Inject private GroupFactory groupFactory;
-	@Inject private MessageFactory messageFactory;
-
 	private GroupListAdapter adapter = null;
+	private ListView list = null;
+
+	// Fields that are accessed from DB threads must be volatile
+	@Inject private volatile CryptoComponent crypto;
+	@Inject private volatile DatabaseComponent db;
+	@Inject @DatabaseExecutor private volatile Executor dbExecutor;
+	@Inject private volatile AuthorFactory authorFactory;
+	@Inject private volatile GroupFactory groupFactory;
+	@Inject private volatile MessageFactory messageFactory;
 
 	@Override
 	public void onCreate(Bundle state) {
@@ -83,7 +85,7 @@ implements OnClickListener, DatabaseListener {
 		layout.setGravity(CENTER_HORIZONTAL);
 
 		adapter = new GroupListAdapter(this);
-		ListView list = new ListView(this);
+		list = new ListView(this);
 		// Give me all the width and all the unused height
 		list.setLayoutParams(CommonLayoutParams.MATCH_WRAP_1);
 		list.setAdapter(adapter);
@@ -112,9 +114,6 @@ implements OnClickListener, DatabaseListener {
 
 	// FIXME: Remove this
 	private void insertFakeMessages() {
-		final DatabaseComponent db = this.db;
-		final GroupFactory groupFactory = this.groupFactory;
-		final MessageFactory messageFactory = this.messageFactory;
 		dbExecutor.execute(new Runnable() {
 			public void run() {
 				try {
@@ -204,18 +203,16 @@ implements OnClickListener, DatabaseListener {
 	@Override
 	public void onResume() {
 		super.onResume();
-		reloadGroupList();
+		loadGroups();
 	}
 
-	private void reloadGroupList() {
-		final DatabaseComponent db = this.db;
+	private void loadGroups() {
 		dbExecutor.execute(new Runnable() {
 			public void run() {
 				try {
 					// Wait for the service to be bound and started
 					serviceConnection.waitForStartup();
-					// Load the groups and message headers from the DB
-					if(LOG.isLoggable(INFO)) LOG.info("Loading groups");
+					// Load the subscribed groups from the DB
 					Collection<Group> groups = db.getSubscriptions();
 					if(LOG.isLoggable(INFO))
 						LOG.info("Loaded " + groups.size() + " groups");
@@ -223,20 +220,20 @@ implements OnClickListener, DatabaseListener {
 					for(Group g : groups) {
 						// Filter out restricted groups
 						if(g.getPublicKey() != null) continue;
+						// Load the message headers
 						Collection<GroupMessageHeader> headers;
 						try {
 							headers = db.getMessageHeaders(g.getId());
 						} catch(NoSuchSubscriptionException e) {
-							// We'll reload the list when we get the event
-							continue;
+							continue; // Unsubscribed since getSubscriptions()
 						}
 						if(LOG.isLoggable(INFO))
 							LOG.info("Loaded " + headers.size() + " headers");
 						if(!headers.isEmpty())
 							items.add(createItem(g, headers));
 					}
-					// Update the group list
-					updateGroupList(Collections.unmodifiableList(items));
+					// Display the groups in the UI
+					displayGroups(Collections.unmodifiableList(items));
 				} catch(DbException e) {
 					if(LOG.isLoggable(WARNING))
 						LOG.log(WARNING, e.toString(), e);
@@ -257,16 +254,29 @@ implements OnClickListener, DatabaseListener {
 		return new GroupListItem(group, sort);
 	}
 
-	private void updateGroupList(final Collection<GroupListItem> items) {
+	private void displayGroups(final Collection<GroupListItem> items) {
 		runOnUiThread(new Runnable() {
 			public void run() {
 				adapter.clear();
 				for(GroupListItem i : items) adapter.add(i);
 				adapter.sort(GroupComparator.INSTANCE);
+				selectFirstUnread();
 			}
 		});
 	}
 
+	private void selectFirstUnread() {
+		int firstUnread = -1, count = adapter.getCount();
+		for(int i = 0; i < count; i++) {
+			if(adapter.getItem(i).getUnreadCount() > 0) {
+				firstUnread = i;
+				break;
+			}
+		}
+		if(firstUnread == -1) list.setSelection(count - 1);
+		else list.setSelection(firstUnread);
+	}
+
 	@Override
 	public void onDestroy() {
 		super.onDestroy();
@@ -279,19 +289,94 @@ implements OnClickListener, DatabaseListener {
 	}
 
 	public void eventOccurred(DatabaseEvent e) {
-		if(e instanceof MessageAddedEvent) {
-			if(LOG.isLoggable(INFO)) LOG.info("Message added, reloading");
-			reloadGroupList();
+		if(e instanceof GroupMessageAddedEvent) {
+			GroupMessageAddedEvent g = (GroupMessageAddedEvent) e;
+			addToGroup(g.getMessage(), g.isIncoming());
 		} else if(e instanceof MessageExpiredEvent) {
 			if(LOG.isLoggable(INFO)) LOG.info("Message removed, reloading");
-			reloadGroupList();
-		} else if(e instanceof SubscriptionAddedEvent) {
-			if(LOG.isLoggable(INFO)) LOG.info("Group added, reloading");
-			reloadGroupList();
+			loadGroups(); // FIXME: Don't reload unnecessarily
 		} else if(e instanceof SubscriptionRemovedEvent) {
-			if(LOG.isLoggable(INFO)) LOG.info("Group removed, reloading");
-			reloadGroupList();
+			removeGroup(((SubscriptionRemovedEvent) e).getGroupId());
+		}
+	}
+
+	private void addToGroup(final Message m, final boolean incoming) {
+		runOnUiThread(new Runnable() {
+			public void run() {
+				GroupId g = m.getGroup().getId();
+				GroupListItem item = findGroup(g);
+				if(item == null) {
+					loadGroup(g, m, incoming);
+				} else if(item.add(m, incoming)) {
+					adapter.sort(GroupComparator.INSTANCE);
+					selectFirstUnread();
+					list.invalidate();
+				}
+			}
+		});
+	}
+
+	private GroupListItem findGroup(GroupId g) {
+		int count = adapter.getCount();
+		for(int i = 0; i < count; i++) {
+			GroupListItem item = adapter.getItem(i);
+			if(item.getGroupId().equals(g)) return item;
 		}
+		return null; // Not found
+	}
+
+	private void loadGroup(final GroupId g, final Message m,
+			final boolean incoming) {
+		dbExecutor.execute(new Runnable() {
+			public void run() {
+				try {
+					// Wait for the service to be bound and started
+					serviceConnection.waitForStartup();
+					// Load the group from the DB and display it in the UI
+					displayGroup(db.getGroup(g), m, incoming);
+				} catch(NoSuchSubscriptionException e) {
+					if(LOG.isLoggable(INFO)) LOG.info("Subscription removed");
+				} catch(DbException e) {
+					if(LOG.isLoggable(WARNING))
+						LOG.log(WARNING, e.toString(), e);
+				} catch(InterruptedException e) {
+					if(LOG.isLoggable(INFO))
+						LOG.info("Interrupted while waiting for service");
+					Thread.currentThread().interrupt();
+				}
+			}
+		});
+	}
+
+	private void displayGroup(final Group g, final Message m,
+			final boolean incoming) {
+		runOnUiThread(new Runnable() {
+			public void run() {
+				// The item may have been added since loadGroup() was called
+				GroupListItem item = findGroup(g.getId());
+				if(item == null) {
+					adapter.add(new GroupListItem(g, m, incoming));
+					adapter.sort(GroupComparator.INSTANCE);
+					selectFirstUnread();
+				} else if(item.add(m, incoming)) {
+					adapter.sort(GroupComparator.INSTANCE);
+					selectFirstUnread();
+					list.invalidate();
+				}
+			}
+		});
+	}
+
+	private void removeGroup(final GroupId g) {
+		runOnUiThread(new Runnable() {
+			public void run() {
+				GroupListItem item = findGroup(g);
+				if(item != null) {
+					adapter.remove(item);
+					selectFirstUnread();
+				}
+			}
+		});
 	}
 
 	private static class GroupComparator implements Comparator<GroupListItem> {
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 38a4513c21..918e272d48 100644
--- a/briar-android/src/net/sf/briar/android/groups/GroupListItem.java
+++ b/briar-android/src/net/sf/briar/android/groups/GroupListItem.java
@@ -1,20 +1,26 @@
 package net.sf.briar.android.groups;
 
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
 import net.sf.briar.android.DescendingHeaderComparator;
 import net.sf.briar.api.db.GroupMessageHeader;
 import net.sf.briar.api.messaging.Author;
 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.MessageId;
 
+// This class is not thread-safe
 class GroupListItem {
 
+	private final Set<MessageId> messageIds = new HashSet<MessageId>();
 	private final Group group;
-	private final String author, subject;
-	private final long timestamp;
-	private final int unread;
+	private String authorName, subject;
+	private long timestamp;
+	private int unread;
 
 	GroupListItem(Group group, List<GroupMessageHeader> headers) {
 		if(headers.isEmpty()) throw new IllegalArgumentException();
@@ -22,13 +28,40 @@ class GroupListItem {
 		Collections.sort(headers, DescendingHeaderComparator.INSTANCE);
 		GroupMessageHeader newest = headers.get(0);
 		Author a = newest.getAuthor();
-		if(a == null) author = null;
-		else author = a.getName();
+		if(a == null) authorName = null;
+		else authorName = a.getName();
 		subject = newest.getSubject();
 		timestamp = newest.getTimestamp();
-		int unread = 0;
-		for(GroupMessageHeader h : headers) if(!h.isRead()) unread++;
-		this.unread = unread;
+		unread = 0;
+		for(GroupMessageHeader h : headers) {
+			if(!h.isRead()) unread++;
+			if(!messageIds.add(h.getId())) throw new IllegalArgumentException();
+		}
+	}
+
+	GroupListItem(Group group, Message first, boolean incoming) {
+		this.group = group;
+		Author a = first.getAuthor();
+		if(a == null) authorName = null;
+		else authorName = a.getName();
+		subject = first.getSubject();
+		timestamp = first.getTimestamp();
+		unread = incoming ? 1 : 0;
+		messageIds.add(first.getId());
+	}
+
+	boolean add(Message m, boolean incoming) {
+		if(!messageIds.add(m.getId())) return false;
+		if(m.getTimestamp() > timestamp) {
+			// The added message is the newest
+			Author a = m.getAuthor();
+			if(a == null) authorName = null;
+			else authorName = a.getName();
+			subject = m.getSubject();
+			timestamp = m.getTimestamp();
+		}
+		if(incoming) unread++;
+		return true;
 	}
 
 	GroupId getGroupId() {
@@ -40,7 +73,7 @@ class GroupListItem {
 	}
 
 	String getAuthorName() {
-		return author;
+		return authorName;
 	}
 
 	String getSubject() {
diff --git a/briar-android/src/net/sf/briar/android/groups/ReadGroupMessageActivity.java b/briar-android/src/net/sf/briar/android/groups/ReadGroupMessageActivity.java
index 9a43b56d4d..845c7b9b1e 100644
--- a/briar-android/src/net/sf/briar/android/groups/ReadGroupMessageActivity.java
+++ b/briar-android/src/net/sf/briar/android/groups/ReadGroupMessageActivity.java
@@ -61,13 +61,7 @@ implements OnClickListener {
 			new BriarServiceConnection();
 
 	@Inject private BundleEncrypter bundleEncrypter;
-	@Inject private DatabaseComponent db;
-	@Inject @DatabaseExecutor private Executor dbExecutor;
-
 	private GroupId groupId = null;
-	private MessageId messageId = null;
-	private AuthorId authorId = null;
-	private String authorName = null;
 	private Rating rating = UNRATED;
 	private boolean read;
 	private ImageView thumb = null;
@@ -76,6 +70,12 @@ implements OnClickListener {
 	private ImageButton replyButton = null;
 	private TextView content = null;
 
+	// Fields that are accessed from DB threads must be volatile
+	@Inject private volatile DatabaseComponent db;
+	@Inject @DatabaseExecutor private volatile Executor dbExecutor;
+	private volatile MessageId messageId = null;
+	private volatile AuthorId authorId = null;
+
 	@Override
 	public void onCreate(Bundle state) {
 		super.onCreate(null);
@@ -91,6 +91,7 @@ implements OnClickListener {
 		if(id == null) throw new IllegalStateException();
 		messageId = new MessageId(id);
 		boolean anonymous = i.getBooleanExtra("net.sf.briar.ANONYMOUS", false);
+		String authorName = null;
 		if(!anonymous) {
 			id = i.getByteArrayExtra("net.sf.briar.AUTHOR_ID");
 			if(id == null) throw new IllegalStateException();
@@ -235,8 +236,6 @@ implements OnClickListener {
 	}
 
 	private void setReadInDatabase(final boolean read) {
-		final DatabaseComponent db = this.db;
-		final MessageId messageId = this.messageId;
 		dbExecutor.execute(new Runnable() {
 			public void run() {
 				try {
@@ -266,8 +265,6 @@ implements OnClickListener {
 	}
 
 	private void loadMessageBody() {
-		final DatabaseComponent db = this.db;
-		final MessageId messageId = this.messageId;
 		dbExecutor.execute(new Runnable() {
 			public void run() {
 				try {
@@ -334,8 +331,6 @@ implements OnClickListener {
 	}
 
 	private void setRatingInDatabase(final Rating r) {
-		final DatabaseComponent db = this.db;
-		final AuthorId authorId = this.authorId;
 		dbExecutor.execute(new Runnable() {
 			public void run() {
 				try {
diff --git a/briar-android/src/net/sf/briar/android/groups/WriteGroupMessageActivity.java b/briar-android/src/net/sf/briar/android/groups/WriteGroupMessageActivity.java
index 7a1a93c06f..6dd8792ea6 100644
--- a/briar-android/src/net/sf/briar/android/groups/WriteGroupMessageActivity.java
+++ b/briar-android/src/net/sf/briar/android/groups/WriteGroupMessageActivity.java
@@ -53,18 +53,19 @@ implements OnClickListener, OnItemSelectedListener {
 			new BriarServiceConnection();
 
 	@Inject private BundleEncrypter bundleEncrypter;
-	@Inject private DatabaseComponent db;
-	@Inject @DatabaseExecutor private Executor dbExecutor;
-	@Inject private MessageFactory messageFactory;
-
-	private Group group = null;
-	private GroupId groupId = null;
-	private MessageId parentId = null;
 	private GroupNameSpinnerAdapter adapter = null;
 	private Spinner spinner = null;
 	private ImageButton sendButton = null;
 	private EditText content = null;
 
+	// Fields that are accessed from DB threads must be volatile
+	@Inject private volatile DatabaseComponent db;
+	@Inject @DatabaseExecutor private volatile Executor dbExecutor;
+	@Inject private volatile MessageFactory messageFactory;
+	private volatile Group group = null;
+	private volatile GroupId groupId = null;
+	private volatile MessageId parentId = null;
+
 	@Override
 	public void onCreate(Bundle state) {
 		super.onCreate(null);
@@ -94,7 +95,7 @@ implements OnClickListener, OnItemSelectedListener {
 		spinner = new Spinner(this);
 		spinner.setAdapter(adapter);
 		spinner.setOnItemSelectedListener(this);
-		loadContactNames();
+		loadGroupList();
 		actionBar.addView(spinner);
 
 		actionBar.addView(new HorizontalSpace(this));
@@ -122,24 +123,12 @@ implements OnClickListener, OnItemSelectedListener {
 				serviceConnection, 0);
 	}
 
-	private void loadContactNames() {
-		final DatabaseComponent db = this.db;
+	private void loadGroupList() {
 		dbExecutor.execute(new Runnable() {
 			public void run() {
 				try {
 					serviceConnection.waitForStartup();
-					final Collection<Group> groups = db.getSubscriptions();
-					runOnUiThread(new Runnable() {
-						public void run() {
-							for(Group g : groups) {
-								if(g.getId().equals(groupId)) {
-									group = g;
-									spinner.setSelection(adapter.getCount());
-								}
-								adapter.add(g);
-							}
-						}
-					});
+					updateGroupList(db.getSubscriptions());
 				} catch(DbException e) {
 					if(LOG.isLoggable(WARNING))
 						LOG.log(WARNING, e.toString(), e);
@@ -151,6 +140,20 @@ implements OnClickListener, OnItemSelectedListener {
 		});
 	}
 
+	private void updateGroupList(final Collection<Group> groups) {
+		runOnUiThread(new Runnable() {
+			public void run() {
+				for(Group g : groups) {
+					if(g.getId().equals(groupId)) {
+						group = g;
+						spinner.setSelection(adapter.getCount());
+					}
+					adapter.add(g);
+				}
+			}
+		});
+	}
+
 	@Override
 	public void onSaveInstanceState(Bundle state) {
 		Parcelable p = content.onSaveInstanceState();
@@ -175,10 +178,6 @@ implements OnClickListener, OnItemSelectedListener {
 	}
 
 	private void storeMessage(final byte[] body) {
-		final DatabaseComponent db = this.db;
-		final MessageFactory messageFactory = this.messageFactory;
-		final Group group = this.group;
-		final MessageId parentId = this.parentId;
 		dbExecutor.execute(new Runnable() {
 			public void run() {
 				try {
diff --git a/briar-android/src/net/sf/briar/android/invitation/AddContactActivity.java b/briar-android/src/net/sf/briar/android/invitation/AddContactActivity.java
index 4c246c4913..6bc03686f8 100644
--- a/briar-android/src/net/sf/briar/android/invitation/AddContactActivity.java
+++ b/briar-android/src/net/sf/briar/android/invitation/AddContactActivity.java
@@ -35,12 +35,8 @@ implements InvitationListener {
 
 	@Inject private BundleEncrypter bundleEncrypter;
 	@Inject private CryptoComponent crypto;
-	@Inject private DatabaseComponent db;
-	@Inject @DatabaseExecutor private Executor dbExecutor;
 	@Inject private InvitationTaskFactory invitationTaskFactory;
 	@Inject private ReferenceManager referenceManager;
-
-	// All of the following must be accessed on the UI thread
 	private AddContactView view = null;
 	private InvitationTask task = null;
 	private long taskHandle = -1;
@@ -52,6 +48,10 @@ implements InvitationListener {
 	private boolean localCompared = false, remoteCompared = false;
 	private boolean localMatched = false, remoteMatched = false;
 
+	// Fields that are accessed from DB threads must be volatile
+	@Inject private volatile DatabaseComponent db;
+	@Inject @DatabaseExecutor private volatile Executor dbExecutor;
+
 	@Override
 	public void onCreate(Bundle state) {
 		super.onCreate(null);
@@ -217,7 +217,6 @@ implements InvitationListener {
 	}
 
 	void addContactAndFinish(final String nickname) {
-		final DatabaseComponent db = this.db;
 		dbExecutor.execute(new Runnable() {
 			public void run() {
 				try {
diff --git a/briar-android/src/net/sf/briar/android/messages/ConversationActivity.java b/briar-android/src/net/sf/briar/android/messages/ConversationActivity.java
index aefd64d0da..ef7252eeda 100644
--- a/briar-android/src/net/sf/briar/android/messages/ConversationActivity.java
+++ b/briar-android/src/net/sf/briar/android/messages/ConversationActivity.java
@@ -5,10 +5,9 @@ import static android.widget.LinearLayout.VERTICAL;
 import static java.util.logging.Level.INFO;
 import static java.util.logging.Level.WARNING;
 
-import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
+import java.util.HashSet;
+import java.util.Set;
 import java.util.concurrent.Executor;
 import java.util.logging.Logger;
 
@@ -28,8 +27,10 @@ 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.messaging.Message;
+import net.sf.briar.api.messaging.MessageId;
 import android.content.Intent;
 import android.os.Bundle;
 import android.view.View;
@@ -51,14 +52,17 @@ implements DatabaseListener, OnClickListener, OnItemClickListener {
 	private final BriarServiceConnection serviceConnection =
 			new BriarServiceConnection();
 
-	@Inject private DatabaseComponent db;
-	@Inject @DatabaseExecutor private Executor dbExecutor;
-
-	private ContactId contactId = null;
+	// The following fields must only be accessed from the UI thread
+	private Set<MessageId> messageIds = new HashSet<MessageId>();
 	private String contactName = null;
 	private ConversationAdapter adapter = null;
 	private ListView list = null;
 
+	// Fields that are accessed from DB threads must be volatile
+	@Inject private volatile DatabaseComponent db;
+	@Inject @DatabaseExecutor private volatile Executor dbExecutor;
+	private volatile ContactId contactId = null;
+
 	@Override
 	public void onCreate(Bundle state) {
 		super.onCreate(null);
@@ -104,24 +108,22 @@ implements DatabaseListener, OnClickListener, OnItemClickListener {
 	@Override
 	public void onResume() {
 		super.onResume();
-		reloadMessageHeaders();
+		loadHeaders();
 	}
 
-	private void reloadMessageHeaders() {
-		final DatabaseComponent db = this.db;
-		final ContactId contactId = this.contactId;
+	private void loadHeaders() {
 		dbExecutor.execute(new Runnable() {
 			public void run() {
 				try {
 					// Wait for the service to be bound and started
 					serviceConnection.waitForStartup();
-					// Load the message headers from the database
+					// Load the headers from the database
 					Collection<PrivateMessageHeader> headers =
 							db.getPrivateMessageHeaders(contactId);
 					if(LOG.isLoggable(INFO))
 						LOG.info("Loaded " + headers.size() + " headers");
-					// Update the conversation
-					updateConversation(headers);
+					// Display the headers in the UI
+					displayHeaders(headers);
 				} catch(NoSuchContactException e) {
 					if(LOG.isLoggable(INFO)) LOG.info("Contact removed");
 					finishOnUiThread();
@@ -137,26 +139,34 @@ implements DatabaseListener, OnClickListener, OnItemClickListener {
 		});
 	}
 
-	private void updateConversation(
+	private void displayHeaders(
 			final Collection<PrivateMessageHeader> headers) {
 		runOnUiThread(new Runnable() {
 			public void run() {
-				List<PrivateMessageHeader> sort =
-						new ArrayList<PrivateMessageHeader>(headers);
-				Collections.sort(sort, AscendingHeaderComparator.INSTANCE);
-				int firstUnread = -1;
+				messageIds.clear();
 				adapter.clear();
-				for(PrivateMessageHeader h : sort) {
-					if(firstUnread == -1 && !h.isRead())
-						firstUnread = adapter.getCount();
+				for(PrivateMessageHeader h : headers) {
+					messageIds.add(h.getId());
 					adapter.add(h);
 				}
-				if(firstUnread == -1) list.setSelection(adapter.getCount() - 1);
-				else list.setSelection(firstUnread);
+				adapter.sort(AscendingHeaderComparator.INSTANCE);
+				selectFirstUnread();
 			}
 		});
 	}
 
+	private void selectFirstUnread() {
+		int firstUnread = -1, count = adapter.getCount();
+		for(int i = 0; i < count; i++) {
+			if(!adapter.getItem(i).isRead()) {
+				firstUnread = i;
+				break;
+			}
+		}
+		if(firstUnread == -1) list.setSelection(count - 1);
+		else list.setSelection(firstUnread);
+	}
+
 	@Override
 	public void onActivityResult(int request, int result, Intent data) {
 		if(result == ReadPrivateMessageActivity.RESULT_PREV) {
@@ -182,17 +192,31 @@ implements DatabaseListener, OnClickListener, OnItemClickListener {
 			ContactRemovedEvent c = (ContactRemovedEvent) e;
 			if(c.getContactId().equals(contactId)) {
 				if(LOG.isLoggable(INFO)) LOG.info("Contact removed");
-				finish();
+				finishOnUiThread();
 			}
-		} else if(e instanceof MessageAddedEvent) {
-			if(LOG.isLoggable(INFO)) LOG.info("Message added, reloading");
-			reloadMessageHeaders();
 		} else if(e instanceof MessageExpiredEvent) {
 			if(LOG.isLoggable(INFO)) LOG.info("Message removed, reloading");
-			reloadMessageHeaders();
+			loadHeaders(); // FIXME: Don't reload unnecessarily
+		} else if(e instanceof PrivateMessageAddedEvent) {
+			PrivateMessageAddedEvent p = (PrivateMessageAddedEvent) e;
+			if(p.getContactId().equals(contactId))
+				addToConversation(p.getMessage(), p.isIncoming());
 		}
 	}
 
+	private void addToConversation(final Message m, final boolean incoming) {
+		runOnUiThread(new Runnable() {
+			public void run() {
+				if(messageIds.add(m.getId())) {
+					adapter.add(new PrivateMessageHeader(m, !incoming, false,
+							contactId, incoming));
+					adapter.sort(AscendingHeaderComparator.INSTANCE);
+					selectFirstUnread();
+				}
+			}
+		});
+	}
+
 	public void onClick(View view) {
 		Intent i = new Intent(this, WritePrivateMessageActivity.class);
 		i.putExtra("net.sf.briar.CONTACT_ID", contactId.getInt());
diff --git a/briar-android/src/net/sf/briar/android/messages/ConversationComparator.java b/briar-android/src/net/sf/briar/android/messages/ConversationComparator.java
deleted file mode 100644
index fcf0b17999..0000000000
--- a/briar-android/src/net/sf/briar/android/messages/ConversationComparator.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package net.sf.briar.android.messages;
-
-import java.util.Comparator;
-
-class ConversationComparator implements Comparator<ConversationListItem> {
-
-	static final ConversationComparator INSTANCE = new ConversationComparator();
-
-	public int compare(ConversationListItem a, ConversationListItem b) {
-		// The item with 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/messages/ConversationListActivity.java b/briar-android/src/net/sf/briar/android/messages/ConversationListActivity.java
index 5eafcf3367..4734728148 100644
--- a/briar-android/src/net/sf/briar/android/messages/ConversationListActivity.java
+++ b/briar-android/src/net/sf/briar/android/messages/ConversationListActivity.java
@@ -9,6 +9,7 @@ import java.io.IOException;
 import java.security.GeneralSecurityException;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -26,11 +27,13 @@ import net.sf.briar.api.ContactId;
 import net.sf.briar.api.db.DatabaseComponent;
 import net.sf.briar.api.db.DatabaseExecutor;
 import net.sf.briar.api.db.DbException;
+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.messaging.Message;
 import net.sf.briar.api.messaging.MessageFactory;
 import android.content.Intent;
@@ -52,11 +55,13 @@ implements OnClickListener, DatabaseListener {
 	private final BriarServiceConnection serviceConnection =
 			new BriarServiceConnection();
 
-	@Inject private DatabaseComponent db;
-	@Inject @DatabaseExecutor private Executor dbExecutor;
-	@Inject private MessageFactory messageFactory;
-
 	private ConversationListAdapter adapter = null;
+	private ListView list = null;
+
+	// Fields that are accessed from DB threads must be volatile
+	@Inject private volatile DatabaseComponent db;
+	@Inject @DatabaseExecutor private volatile Executor dbExecutor;
+	@Inject private volatile MessageFactory messageFactory;
 
 	@Override
 	public void onCreate(Bundle state) {
@@ -67,7 +72,7 @@ implements OnClickListener, DatabaseListener {
 		layout.setGravity(CENTER_HORIZONTAL);
 
 		adapter = new ConversationListAdapter(this);
-		ListView list = new ListView(this);
+		list = new ListView(this);
 		// Give me all the width and all the unused height
 		list.setLayoutParams(CommonLayoutParams.MATCH_WRAP_1);
 		list.setAdapter(adapter);
@@ -96,8 +101,6 @@ implements OnClickListener, DatabaseListener {
 
 	// FIXME: Remove this
 	private void insertFakeMessages() {
-		final DatabaseComponent db = this.db;
-		final MessageFactory messageFactory = this.messageFactory;
 		dbExecutor.execute(new Runnable() {
 			public void run() {
 				try {
@@ -160,29 +163,26 @@ implements OnClickListener, DatabaseListener {
 	@Override
 	public void onResume() {
 		super.onResume();
-		reloadMessageHeaders();
+		loadHeaders();
 	}
 
-	private void reloadMessageHeaders() {
-		final DatabaseComponent db = this.db;
+	private void loadHeaders() {
 		dbExecutor.execute(new Runnable() {
 			public void run() {
 				try {
 					// Wait for the service to be bound and started
 					serviceConnection.waitForStartup();
 					// Load the contact list from the database
-					if(LOG.isLoggable(INFO)) LOG.info("Loading contacts");
 					Collection<Contact> contacts = db.getContacts();
 					if(LOG.isLoggable(INFO))
 						LOG.info("Loaded " + contacts.size() + " contacts");
-					// Load the message headers from the database
-					if(LOG.isLoggable(INFO)) LOG.info("Loading headers");
+					// Load the headers from the database
 					Collection<PrivateMessageHeader> headers =
 							db.getPrivateMessageHeaders();
 					if(LOG.isLoggable(INFO))
 						LOG.info("Loaded " + headers.size() + " headers");
-					// Update the conversation list
-					updateConversationList(contacts, headers);
+					// Display the headers in the UI
+					displayHeaders(contacts, headers);
 				} catch(DbException e) {
 					if(LOG.isLoggable(WARNING))
 						LOG.log(WARNING, e.toString(), e);
@@ -195,7 +195,7 @@ implements OnClickListener, DatabaseListener {
 		});
 	}
 
-	private void updateConversationList(final Collection<Contact> contacts,
+	private void displayHeaders(final Collection<Contact> contacts,
 			final Collection<PrivateMessageHeader> headers) {
 		runOnUiThread(new Runnable() {
 			public void run() {
@@ -203,6 +203,7 @@ implements OnClickListener, DatabaseListener {
 				for(ConversationListItem i : sortHeaders(contacts, headers))
 					adapter.add(i);
 				adapter.sort(ConversationComparator.INSTANCE);
+				selectFirstUnread();
 			}
 		});
 	}
@@ -230,6 +231,18 @@ implements OnClickListener, DatabaseListener {
 		return list;
 	}
 
+	private void selectFirstUnread() {
+		int firstUnread = -1, count = adapter.getCount();
+		for(int i = 0; i < count; i++) {
+			if(adapter.getItem(i).getUnreadCount() > 0) {
+				firstUnread = i;
+				break;
+			}
+		}
+		if(firstUnread == -1) list.setSelection(count - 1);
+		else list.setSelection(firstUnread);
+	}
+
 	@Override
 	public void onDestroy() {
 		super.onDestroy();
@@ -242,12 +255,108 @@ implements OnClickListener, DatabaseListener {
 	}
 
 	public void eventOccurred(DatabaseEvent e) {
-		if(e instanceof MessageAddedEvent) {
-			if(LOG.isLoggable(INFO)) LOG.info("Message added, reloading");
-			reloadMessageHeaders();
+		if(e instanceof ContactRemovedEvent) {
+			removeContact(((ContactRemovedEvent) e).getContactId());
 		} else if(e instanceof MessageExpiredEvent) {
 			if(LOG.isLoggable(INFO)) LOG.info("Message removed, reloading");
-			reloadMessageHeaders();
+			loadHeaders(); // FIXME: Don't reload unnecessarily
+		} else if(e instanceof PrivateMessageAddedEvent) {
+			PrivateMessageAddedEvent p = (PrivateMessageAddedEvent) e;
+			addToConversation(p.getContactId(), p.getMessage(), p.isIncoming());
+		}
+	}
+
+	private void removeContact(final ContactId c) {
+		runOnUiThread(new Runnable() {
+			public void run() {
+				ConversationListItem item = findConversation(c);
+				if(item != null) {
+					adapter.remove(item);
+					selectFirstUnread();
+				}
+			}
+		});
+	}
+
+	private ConversationListItem findConversation(ContactId c) {
+		int count = adapter.getCount();
+		for(int i = 0; i < count; i++) {
+			ConversationListItem item = adapter.getItem(i);
+			if(item.getContactId().equals(c)) return item;
+		}
+		return null; // Not found
+	}
+
+	private void addToConversation(final ContactId c, final Message m,
+			final boolean incoming) {
+		runOnUiThread(new Runnable() {
+			public void run() {
+				ConversationListItem item = findConversation(c);
+				if(item == null) {
+					loadContact(c, m, incoming);
+				} else if(item.add(m, incoming)) {
+					adapter.sort(ConversationComparator.INSTANCE);
+					selectFirstUnread();
+					list.invalidate();
+				}
+			}
+		});
+	}
+
+	private void loadContact(final ContactId c, final Message m,
+			final boolean incoming) {
+		dbExecutor.execute(new Runnable() {
+			public void run() {
+				try {
+					// Wait for the service to be bound and started
+					serviceConnection.waitForStartup();
+					// Load the contact from the DB and display it in the UI
+					displayContact(db.getContact(c), m, incoming);
+				} catch(NoSuchContactException e) {
+					if(LOG.isLoggable(INFO)) LOG.info("Contact removed");
+				} catch(DbException e) {
+					if(LOG.isLoggable(WARNING))
+						LOG.log(WARNING, e.toString(), e);
+				} catch(InterruptedException e) {
+					if(LOG.isLoggable(INFO))
+						LOG.info("Interrupted while waiting for service");
+					Thread.currentThread().interrupt();
+				}
+			}
+		});
+	}
+
+	private void displayContact(final Contact c, final Message m,
+			final boolean incoming) {
+		runOnUiThread(new Runnable() {
+			public void run() {
+				// The item may have been added since loadContact() was called
+				ConversationListItem item = findConversation(c.getId());
+				if(item == null) {
+					adapter.add(new ConversationListItem(c, m, incoming));
+					adapter.sort(ConversationComparator.INSTANCE);
+					selectFirstUnread();
+				} else if(item.add(m, incoming)) {
+					adapter.sort(ConversationComparator.INSTANCE);
+					selectFirstUnread();
+					list.invalidate();
+				}
+			}
+		});
+	}
+
+	private static class ConversationComparator
+	implements Comparator<ConversationListItem> {
+
+		static final ConversationComparator INSTANCE =
+				new ConversationComparator();
+
+		public int compare(ConversationListItem a, ConversationListItem b) {
+			// The item with the newest message comes first
+			long aTime = a.getTimestamp(), bTime = b.getTimestamp();
+			if(aTime > bTime) return -1;
+			if(aTime < bTime) return 1;
+			return 0;
 		}
 	}
 }
diff --git a/briar-android/src/net/sf/briar/android/messages/ConversationListItem.java b/briar-android/src/net/sf/briar/android/messages/ConversationListItem.java
index eec30a91c8..edec2e49b3 100644
--- a/briar-android/src/net/sf/briar/android/messages/ConversationListItem.java
+++ b/briar-android/src/net/sf/briar/android/messages/ConversationListItem.java
@@ -1,19 +1,25 @@
 package net.sf.briar.android.messages;
 
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
 import net.sf.briar.android.DescendingHeaderComparator;
 import net.sf.briar.api.Contact;
 import net.sf.briar.api.ContactId;
 import net.sf.briar.api.db.PrivateMessageHeader;
+import net.sf.briar.api.messaging.Message;
+import net.sf.briar.api.messaging.MessageId;
 
+// This class is not thread-safe
 class ConversationListItem {
 
+	private final Set<MessageId> messageIds = new HashSet<MessageId>();
 	private final Contact contact;
-	private final String subject;
-	private final long timestamp;
-	private final int unread;
+	private String subject;
+	private long timestamp;
+	private int unread;
 
 	ConversationListItem(Contact contact, List<PrivateMessageHeader> headers) {
 		if(headers.isEmpty()) throw new IllegalArgumentException();
@@ -21,9 +27,30 @@ class ConversationListItem {
 		Collections.sort(headers, DescendingHeaderComparator.INSTANCE);
 		subject = headers.get(0).getSubject();
 		timestamp = headers.get(0).getTimestamp();
-		int unread = 0;
-		for(PrivateMessageHeader h : headers) if(!h.isRead()) unread++;
-		this.unread = unread;
+		unread = 0;
+		for(PrivateMessageHeader h : headers) {
+			if(!h.isRead()) unread++;
+			if(!messageIds.add(h.getId())) throw new IllegalArgumentException();
+		}
+	}
+
+	ConversationListItem(Contact contact, Message first, boolean incoming) {
+		this.contact = contact;
+		subject = first.getSubject();
+		timestamp = first.getTimestamp();
+		unread = incoming ? 1 : 0;
+		messageIds.add(first.getId());
+	}
+
+	boolean add(Message m, boolean incoming) {
+		if(!messageIds.add(m.getId())) return false;
+		if(m.getTimestamp() > timestamp) {
+			// The added message is the newest
+			subject = m.getSubject();
+			timestamp = m.getTimestamp();
+		}
+		if(incoming) unread++;
+		return true;
 	}
 
 	ContactId getContactId() {
diff --git a/briar-android/src/net/sf/briar/android/messages/ReadPrivateMessageActivity.java b/briar-android/src/net/sf/briar/android/messages/ReadPrivateMessageActivity.java
index 3183aae7cf..30a17a264b 100644
--- a/briar-android/src/net/sf/briar/android/messages/ReadPrivateMessageActivity.java
+++ b/briar-android/src/net/sf/briar/android/messages/ReadPrivateMessageActivity.java
@@ -53,16 +53,17 @@ implements OnClickListener {
 			new BriarServiceConnection();
 
 	@Inject private BundleEncrypter bundleEncrypter;
-	@Inject private DatabaseComponent db;
-	@Inject @DatabaseExecutor private Executor dbExecutor;
-
 	private ContactId contactId = null;
-	private MessageId messageId = null;
 	private boolean read;
 	private ImageButton readButton = null, prevButton = null, nextButton = null;
 	private ImageButton replyButton = null;
 	private TextView content = null;
 
+	// Fields that are accessed from DB threads must be volatile
+	@Inject private volatile DatabaseComponent db;
+	@Inject @DatabaseExecutor private volatile Executor dbExecutor;
+	private volatile MessageId messageId = null;
+
 	@Override
 	public void onCreate(Bundle state) {
 		super.onCreate(null);
@@ -186,8 +187,6 @@ implements OnClickListener {
 	}
 
 	private void setReadInDatabase(final boolean read) {
-		final DatabaseComponent db = this.db;
-		final MessageId messageId = this.messageId;
 		dbExecutor.execute(new Runnable() {
 			public void run() {
 				try {
@@ -217,8 +216,6 @@ implements OnClickListener {
 	}
 
 	private void loadMessageBody() {
-		final DatabaseComponent db = this.db;
-		final MessageId messageId = this.messageId;
 		dbExecutor.execute(new Runnable() {
 			public void run() {
 				try {
diff --git a/briar-android/src/net/sf/briar/android/messages/WritePrivateMessageActivity.java b/briar-android/src/net/sf/briar/android/messages/WritePrivateMessageActivity.java
index ced62326ac..589cdc31ef 100644
--- a/briar-android/src/net/sf/briar/android/messages/WritePrivateMessageActivity.java
+++ b/briar-android/src/net/sf/briar/android/messages/WritePrivateMessageActivity.java
@@ -53,17 +53,18 @@ implements OnClickListener, OnItemSelectedListener {
 			new BriarServiceConnection();
 
 	@Inject private BundleEncrypter bundleEncrypter;
-	@Inject private DatabaseComponent db;
-	@Inject @DatabaseExecutor private Executor dbExecutor;
-	@Inject private MessageFactory messageFactory;
-
-	private ContactId contactId = null;
-	private MessageId parentId = null;
 	private ContactNameSpinnerAdapter adapter = null;
 	private Spinner spinner = null;
 	private ImageButton sendButton = null;
 	private EditText content = null;
 
+	// Fields that are accessed from DB threads must be volatile
+	@Inject private volatile DatabaseComponent db;
+	@Inject @DatabaseExecutor private volatile Executor dbExecutor;
+	@Inject private volatile MessageFactory messageFactory;
+	private volatile ContactId contactId = null;
+	private volatile MessageId parentId = null;
+
 	@Override
 	public void onCreate(Bundle state) {
 		super.onCreate(null);
@@ -93,7 +94,7 @@ implements OnClickListener, OnItemSelectedListener {
 		spinner = new Spinner(this);
 		spinner.setAdapter(adapter);
 		spinner.setOnItemSelectedListener(this);
-		loadContactNames();
+		loadContactList();
 		actionBar.addView(spinner);
 
 		actionBar.addView(new HorizontalSpace(this));
@@ -121,22 +122,12 @@ implements OnClickListener, OnItemSelectedListener {
 				serviceConnection, 0);
 	}
 
-	private void loadContactNames() {
-		final DatabaseComponent db = this.db;
+	private void loadContactList() {
 		dbExecutor.execute(new Runnable() {
 			public void run() {
 				try {
 					serviceConnection.waitForStartup();
-					final Collection<Contact> contacts = db.getContacts();
-					runOnUiThread(new Runnable() {
-						public void run() {
-							for(Contact c : contacts) {
-								if(c.getId().equals(contactId))
-									spinner.setSelection(adapter.getCount());
-								adapter.add(c);
-							}
-						}
-					});
+					updateContactList(db.getContacts());
 				} catch(DbException e) {
 					if(LOG.isLoggable(WARNING))
 						LOG.log(WARNING, e.toString(), e);
@@ -148,6 +139,18 @@ implements OnClickListener, OnItemSelectedListener {
 		});
 	}
 
+	private void updateContactList(final Collection<Contact> contacts) {
+		runOnUiThread(new Runnable() {
+			public void run() {
+				for(Contact c : contacts) {
+					if(c.getId().equals(contactId))
+						spinner.setSelection(adapter.getCount());
+					adapter.add(c);
+				}
+			}
+		});
+	}
+
 	@Override
 	public void onSaveInstanceState(Bundle state) {
 		Parcelable p = content.onSaveInstanceState();
@@ -172,10 +175,6 @@ implements OnClickListener, OnItemSelectedListener {
 	}
 
 	private void storeMessage(final byte[] body) {
-		final DatabaseComponent db = this.db;
-		final MessageFactory messageFactory = this.messageFactory;
-		final ContactId contactId = this.contactId;
-		final MessageId parentId = this.parentId;
 		dbExecutor.execute(new Runnable() {
 			public void run() {
 				try {
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 b930322fb4..3e466d1918 100644
--- a/briar-api/src/net/sf/briar/api/db/DatabaseComponent.java
+++ b/briar-api/src/net/sf/briar/api/db/DatabaseComponent.java
@@ -157,9 +157,15 @@ public interface DatabaseComponent {
 	/** Returns the configuration for the given transport. */
 	TransportConfig getConfig(TransportId t) throws DbException;
 
+	/** Returns the contact with the given ID. */
+	Contact getContact(ContactId c) throws DbException;
+
 	/** Returns all contacts. */
 	Collection<Contact> getContacts() throws DbException;
 
+	/** Returns the group with the given ID, if the user subscribes to it. */
+	Group getGroup(GroupId g) throws DbException;
+
 	/** Returns the local transport properties for the given transport. */
 	TransportProperties getLocalProperties(TransportId t) 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
index 543f34a939..a0d796a748 100644
--- a/briar-api/src/net/sf/briar/api/db/GroupMessageHeader.java
+++ b/briar-api/src/net/sf/briar/api/db/GroupMessageHeader.java
@@ -1,20 +1,31 @@
 package net.sf.briar.api.db;
 
+import net.sf.briar.api.Rating;
 import net.sf.briar.api.messaging.Author;
 import net.sf.briar.api.messaging.GroupId;
+import net.sf.briar.api.messaging.Message;
 import net.sf.briar.api.messaging.MessageId;
 
 public class GroupMessageHeader extends MessageHeader {
 
 	private final GroupId groupId;
 	private final Author author;
+	private final Rating rating;
 
 	public GroupMessageHeader(MessageId id, MessageId parent,
 			String contentType, String subject, long timestamp, boolean read,
-			boolean starred, GroupId groupId, Author author) {
+			boolean starred, GroupId groupId, Author author, Rating rating) {
 		super(id, parent, contentType, subject, timestamp, read, starred);
 		this.groupId = groupId;
 		this.author = author;
+		this.rating = rating;
+	}
+
+	public GroupMessageHeader(Message m, boolean read, boolean starred,
+			Rating rating) {
+		this(m.getId(), m.getParent(), m.getContentType(), m.getSubject(),
+				m.getTimestamp(), read, starred, m.getGroup().getId(),
+				m.getAuthor(), rating);
 	}
 
 	/** Returns the ID of the group to which the message belongs. */
@@ -28,4 +39,12 @@ public class GroupMessageHeader extends MessageHeader {
 	public Author getAuthor() {
 		return author;
 	}
+
+	/**
+	 * Returns the rating for the message's author, or Rating.UNRATED if this
+	 * is an anonymous message.
+	 */
+	public Rating getRating() {
+		return rating;
+	}
 }
diff --git a/briar-api/src/net/sf/briar/api/db/PrivateMessageHeader.java b/briar-api/src/net/sf/briar/api/db/PrivateMessageHeader.java
index af55211c66..0add5ab62a 100644
--- a/briar-api/src/net/sf/briar/api/db/PrivateMessageHeader.java
+++ b/briar-api/src/net/sf/briar/api/db/PrivateMessageHeader.java
@@ -1,6 +1,7 @@
 package net.sf.briar.api.db;
 
 import net.sf.briar.api.ContactId;
+import net.sf.briar.api.messaging.Message;
 import net.sf.briar.api.messaging.MessageId;
 
 public class PrivateMessageHeader extends MessageHeader {
@@ -16,6 +17,12 @@ public class PrivateMessageHeader extends MessageHeader {
 		this.incoming = incoming;
 	}
 
+	public PrivateMessageHeader(Message m, boolean read, boolean starred,
+			ContactId contactId, boolean incoming) {
+		this(m.getId(), m.getParent(), m.getContentType(), m.getSubject(),
+				m.getTimestamp(), read, starred, contactId, incoming);
+	}
+
 	/**
 	 * Returns the ID of the contact who is the sender (if incoming) or
 	 * recipient (if outgoing) of this message.
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
new file mode 100644
index 0000000000..acf9ca49bc
--- /dev/null
+++ b/briar-api/src/net/sf/briar/api/db/event/GroupMessageAddedEvent.java
@@ -0,0 +1,23 @@
+package net.sf.briar.api.db.event;
+
+import net.sf.briar.api.messaging.Message;
+
+/** An event that is broadcast when a group message is added to the database. */
+public class GroupMessageAddedEvent extends DatabaseEvent {
+
+	private final Message message;
+	private final boolean incoming;
+
+	public GroupMessageAddedEvent(Message message, boolean incoming) {
+		this.message = message;
+		this.incoming = incoming;
+	}
+
+	public Message getMessage() {
+		return message;
+	}
+
+	public boolean isIncoming() {
+		return incoming;
+	}
+}
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
deleted file mode 100644
index d28dd1664d..0000000000
--- a/briar-api/src/net/sf/briar/api/db/event/MessageAddedEvent.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package net.sf.briar.api.db.event;
-
-/**
- * An event that is broadcast when one or more messages are added to the
- * database.
- */
-public class MessageAddedEvent extends DatabaseEvent {
-
-}
diff --git a/briar-api/src/net/sf/briar/api/db/event/MessageReceivedEvent.java b/briar-api/src/net/sf/briar/api/db/event/MessageReceivedEvent.java
index 4a5ae2c688..2d338abf96 100644
--- a/briar-api/src/net/sf/briar/api/db/event/MessageReceivedEvent.java
+++ b/briar-api/src/net/sf/briar/api/db/event/MessageReceivedEvent.java
@@ -1,6 +1,17 @@
 package net.sf.briar.api.db.event;
 
+import net.sf.briar.api.ContactId;
+
 /** An event that is broadcast when a message is received. */
 public class MessageReceivedEvent extends DatabaseEvent {
 
+	private final ContactId contactId;
+
+	public MessageReceivedEvent(ContactId contactId) {
+		this.contactId = contactId;
+	}
+
+	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
new file mode 100644
index 0000000000..45bcd16bd3
--- /dev/null
+++ b/briar-api/src/net/sf/briar/api/db/event/PrivateMessageAddedEvent.java
@@ -0,0 +1,33 @@
+package net.sf.briar.api.db.event;
+
+import net.sf.briar.api.ContactId;
+import net.sf.briar.api.messaging.Message;
+
+/**
+ * An event that is broadcast when a private message is added to the database.
+ */
+public class PrivateMessageAddedEvent extends DatabaseEvent {
+
+	private final Message message;
+	private final ContactId contactId;
+	private final boolean incoming;
+
+	public PrivateMessageAddedEvent(Message message, ContactId contactId,
+			boolean incoming) {
+		this.message = message;
+		this.contactId = contactId;
+		this.incoming = incoming;
+	}
+
+	public Message getMessage() {
+		return message;
+	}
+
+	public ContactId getContactId() {
+		return contactId;
+	}
+
+	public boolean isIncoming() {
+		return incoming;
+	}
+}
diff --git a/briar-api/src/net/sf/briar/api/db/event/SubscriptionAddedEvent.java b/briar-api/src/net/sf/briar/api/db/event/SubscriptionAddedEvent.java
index 76b39be75b..1955d8f9df 100644
--- a/briar-api/src/net/sf/briar/api/db/event/SubscriptionAddedEvent.java
+++ b/briar-api/src/net/sf/briar/api/db/event/SubscriptionAddedEvent.java
@@ -1,17 +1,17 @@
 package net.sf.briar.api.db.event;
 
-import net.sf.briar.api.messaging.GroupId;
+import net.sf.briar.api.messaging.Group;
 
 /** An event that is broadcast when the user subscribes to a group. */
 public class SubscriptionAddedEvent extends DatabaseEvent {
 
-	private final GroupId groupId;
+	private final Group group;
 
-	public SubscriptionAddedEvent(GroupId groupId) {
-		this.groupId = groupId;
+	public SubscriptionAddedEvent(Group group) {
+		this.group = group;
 	}
 
-	public GroupId getGroupId() {
-		return groupId;
+	public Group getGroup() {
+		return group;
 	}
 }
diff --git a/briar-core/src/net/sf/briar/db/Database.java b/briar-core/src/net/sf/briar/db/Database.java
index 97df26ac6e..0dc1c9d360 100644
--- a/briar-core/src/net/sf/briar/db/Database.java
+++ b/briar-core/src/net/sf/briar/db/Database.java
@@ -202,6 +202,13 @@ interface Database<T> {
 	 */
 	TransportConfig getConfig(T txn, TransportId t) throws DbException;
 
+	/**
+	 * Returns the contact with the given ID.
+	 * <p>
+	 * Locking: contact read, window read.
+	 */
+	Contact getContact(T txn, ContactId c) throws DbException;
+
 	/**
 	 * Returns the IDs of all contacts.
 	 * <p>
@@ -230,6 +237,11 @@ interface Database<T> {
 	 */
 	long getFreeSpace() throws DbException;
 
+	/**
+	 * Returns the group with the given ID, if the user subscribes to it.
+	 */
+	Group getGroup(T txn, GroupId g) 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
diff --git a/briar-core/src/net/sf/briar/db/DatabaseComponentImpl.java b/briar-core/src/net/sf/briar/db/DatabaseComponentImpl.java
index bd34f2e3f5..67287cd99a 100644
--- a/briar-core/src/net/sf/briar/db/DatabaseComponentImpl.java
+++ b/briar-core/src/net/sf/briar/db/DatabaseComponentImpl.java
@@ -41,11 +41,12 @@ 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.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.RatingChangedEvent;
 import net.sf.briar.api.db.event.RemoteRetentionTimeUpdatedEvent;
 import net.sf.briar.api.db.event.RemoteSubscriptionsUpdatedEvent;
@@ -286,7 +287,7 @@ DatabaseCleaner.Callback {
 		} finally {
 			contactLock.readLock().unlock();
 		}
-		if(added) callListeners(new MessageAddedEvent());
+		if(added) callListeners(new GroupMessageAddedEvent(m, false));
 	}
 
 	/**
@@ -399,7 +400,7 @@ DatabaseCleaner.Callback {
 		} finally {
 			contactLock.readLock().unlock();
 		}
-		if(added) callListeners(new MessageAddedEvent());
+		if(added) callListeners(new PrivateMessageAddedEvent(m, c, false));
 	}
 
 	public void addSecrets(Collection<TemporarySecret> secrets)
@@ -844,6 +845,30 @@ DatabaseCleaner.Callback {
 		}
 	}
 
+	public Contact getContact(ContactId c) throws DbException {
+		contactLock.readLock().lock();
+		try {
+			windowLock.readLock().lock();
+			try {
+				T txn = db.startTransaction();
+				try {
+					if(!db.containsContact(txn, c))
+						throw new NoSuchContactException();
+					Contact contact = db.getContact(txn, c);
+					db.commitTransaction(txn);
+					return contact;
+				} catch(DbException e) {
+					db.abortTransaction(txn);
+					throw e;
+				}
+			} finally {
+				windowLock.readLock().unlock();
+			}
+		} finally {
+			contactLock.readLock().unlock();
+		}
+	}
+
 	public Collection<Contact> getContacts() throws DbException {
 		contactLock.readLock().lock();
 		try {
@@ -866,6 +891,25 @@ DatabaseCleaner.Callback {
 		}
 	}
 
+	public Group getGroup(GroupId g) throws DbException {
+		subscriptionLock.readLock().lock();
+		try {
+			T txn = db.startTransaction();
+			try {
+				if(!db.containsSubscription(txn, g))
+					throw new NoSuchSubscriptionException();
+				Group group = db.getGroup(txn, g);
+				db.commitTransaction(txn);
+				return group;
+			} catch(DbException e) {
+				db.abortTransaction(txn);
+				throw e;
+			}
+		} finally {
+			subscriptionLock.readLock().unlock();
+		}
+	}
+
 	public TransportProperties getLocalProperties(TransportId t)
 			throws DbException {
 		transportLock.readLock().lock();
@@ -1301,8 +1345,12 @@ DatabaseCleaner.Callback {
 		} finally {
 			contactLock.readLock().unlock();
 		}
-		callListeners(new MessageReceivedEvent());
-		if(added) callListeners(new MessageAddedEvent());
+		callListeners(new MessageReceivedEvent(c));
+		if(added) {
+			if(m.getGroup() == null)
+				callListeners(new PrivateMessageAddedEvent(m, c, true));
+			else callListeners(new GroupMessageAddedEvent(m, true));
+		}
 	}
 
 	/**
@@ -1795,7 +1843,7 @@ DatabaseCleaner.Callback {
 		} finally {
 			subscriptionLock.writeLock().unlock();
 		}
-		if(added) callListeners(new SubscriptionAddedEvent(g.getId()));
+		if(added) callListeners(new SubscriptionAddedEvent(g));
 		return added;
 	}
 
diff --git a/briar-core/src/net/sf/briar/db/JdbcDatabase.java b/briar-core/src/net/sf/briar/db/JdbcDatabase.java
index 0877effb7c..e0d7150e58 100644
--- a/briar-core/src/net/sf/briar/db/JdbcDatabase.java
+++ b/briar-core/src/net/sf/briar/db/JdbcDatabase.java
@@ -1047,6 +1047,31 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
+	public Contact getContact(Connection txn, ContactId c) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT name, lastConnected"
+					+ " FROM contacts AS c"
+					+ " JOIN connectionTimes AS ct"
+					+ " ON c.contactId = ct.contactId"
+					+ " WHERE c.contactId = ?";
+			ps = txn.prepareStatement(sql);
+			ps.setInt(1, c.getInt());
+			rs = ps.executeQuery();
+			if(!rs.next()) throw new DbStateException();
+			String name = rs.getString(1);
+			long lastConnected = rs.getLong(2);
+			rs.close();
+			ps.close();
+			return new Contact(c, name, lastConnected);
+		} catch(SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
 	public Collection<ContactId> getContactIds(Connection txn)
 			throws DbException {
 		PreparedStatement ps = null;
@@ -1124,6 +1149,27 @@ abstract class JdbcDatabase implements Database<Connection> {
 		}
 	}
 
+	public Group getGroup(Connection txn, GroupId g) throws DbException {
+		PreparedStatement ps = null;
+		ResultSet rs = null;
+		try {
+			String sql = "SELECT name, key 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[] publicKey = rs.getBytes(2);
+			rs.close();
+			ps.close();
+			return new Group(g, name, publicKey);
+		} catch(SQLException e) {
+			tryToClose(rs);
+			tryToClose(ps);
+			throw new DbException(e);
+		}
+	}
+
 	public MessageId getGroupMessageParent(Connection txn, MessageId m)
 			throws DbException {
 		PreparedStatement ps = null;
@@ -1228,10 +1274,12 @@ abstract class JdbcDatabase implements Database<Connection> {
 		PreparedStatement ps = null;
 		ResultSet rs = null;
 		try {
-			String sql = "SELECT messageId, parentId, authorId, authorName,"
+			String sql = "SELECT messageId, parentId, m.authorId, authorName,"
 					+ " authorKey, contentType, subject, timestamp, read,"
-					+ " starred"
-					+ " FROM messages"
+					+ " starred, rating"
+					+ " FROM messages AS m"
+					+ " LEFT OUTER JOIN ratings AS r"
+					+ " ON m.authorId = r.authorId"
 					+ " WHERE groupId = ?";
 			ps = txn.prepareStatement(sql);
 			ps.setBytes(1, g.getBytes());
@@ -1242,13 +1290,18 @@ abstract class JdbcDatabase implements Database<Connection> {
 				MessageId id = new MessageId(rs.getBytes(1));
 				byte[] b = rs.getBytes(2);
 				MessageId parent = b == null ? null : new MessageId(b);
-				Author author = null;
+				Author author;
+				Rating rating;
 				b = rs.getBytes(3);
-				if(b != null) {
+				if(b == null) {
+					author = null;
+					rating = UNRATED;
+				} else {
 					AuthorId authorId = new AuthorId(b);
 					String authorName = rs.getString(4);
 					byte[] authorKey = rs.getBytes(5);
 					author = new Author(authorId, authorName, authorKey);
+					rating = Rating.values()[rs.getByte(11)];
 				}
 				String contentType = rs.getString(6);
 				String subject = rs.getString(7);
@@ -1256,7 +1309,7 @@ abstract class JdbcDatabase implements Database<Connection> {
 				boolean read = rs.getBoolean(9);
 				boolean starred = rs.getBoolean(10);
 				headers.add(new GroupMessageHeader(id, parent, contentType,
-						subject, timestamp, read, starred, g, author));
+						subject, timestamp, read, starred, g, author, rating));
 			}
 			rs.close();
 			ps.close();
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 2681ec67b5..c0604546f7 100644
--- a/briar-core/src/net/sf/briar/messaging/duplex/DuplexConnection.java
+++ b/briar-core/src/net/sf/briar/messaging/duplex/DuplexConnection.java
@@ -30,11 +30,12 @@ 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.RatingChangedEvent;
 import net.sf.briar.api.db.event.RemoteRetentionTimeUpdatedEvent;
 import net.sf.briar.api.db.event.RemoteSubscriptionsUpdatedEvent;
@@ -134,6 +135,9 @@ abstract class DuplexConnection implements DatabaseListener {
 		if(e instanceof ContactRemovedEvent) {
 			ContactRemovedEvent c = (ContactRemovedEvent) e;
 			if(contactId.equals(c.getContactId())) dispose(false, true);
+		} else if(e instanceof GroupMessageAddedEvent) {
+			if(canSendOffer.getAndSet(false))
+				dbExecutor.execute(new GenerateOffer());
 		} else if(e instanceof MessageExpiredEvent) {
 			dbExecutor.execute(new GenerateRetentionUpdate());
 		} else if(e instanceof LocalSubscriptionsUpdatedEvent) {
@@ -143,11 +147,15 @@ abstract class DuplexConnection implements DatabaseListener {
 				dbExecutor.execute(new GenerateSubscriptionUpdate());
 		} else if(e instanceof LocalTransportsUpdatedEvent) {
 			dbExecutor.execute(new GenerateTransportUpdates());
-		} else if(e instanceof MessageAddedEvent) {
-			if(canSendOffer.getAndSet(false))
-				dbExecutor.execute(new GenerateOffer());
 		} else if(e instanceof MessageReceivedEvent) {
-			dbExecutor.execute(new GenerateAcks());
+			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 RatingChangedEvent) {
 			RatingChangedEvent r = (RatingChangedEvent) e;
 			if(r.getRating() == GOOD && canSendOffer.getAndSet(false))
diff --git a/briar-tests/src/net/sf/briar/db/DatabaseComponentTest.java b/briar-tests/src/net/sf/briar/db/DatabaseComponentTest.java
index 63d8330ada..f1622c17c0 100644
--- a/briar-tests/src/net/sf/briar/db/DatabaseComponentTest.java
+++ b/briar-tests/src/net/sf/briar/db/DatabaseComponentTest.java
@@ -20,12 +20,14 @@ import net.sf.briar.api.TransportConfig;
 import net.sf.briar.api.TransportProperties;
 import net.sf.briar.api.db.DatabaseComponent;
 import net.sf.briar.api.db.NoSuchContactException;
+import net.sf.briar.api.db.NoSuchSubscriptionException;
 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.LocalSubscriptionsUpdatedEvent;
-import net.sf.briar.api.db.event.MessageAddedEvent;
+import net.sf.briar.api.db.event.PrivateMessageAddedEvent;
 import net.sf.briar.api.db.event.RatingChangedEvent;
 import net.sf.briar.api.db.event.SubscriptionAddedEvent;
 import net.sf.briar.api.db.event.SubscriptionRemovedEvent;
@@ -503,11 +505,11 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		final ShutdownManager shutdown = context.mock(ShutdownManager.class);
 		context.checking(new Expectations() {{
 			// Check whether the contact is in the DB (which it's not)
-			exactly(27).of(database).startTransaction();
+			exactly(28).of(database).startTransaction();
 			will(returnValue(txn));
-			exactly(27).of(database).containsContact(txn, contactId);
+			exactly(28).of(database).containsContact(txn, contactId);
 			will(returnValue(false));
-			exactly(27).of(database).abortTransaction(txn);
+			exactly(28).of(database).abortTransaction(txn);
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
@@ -572,6 +574,11 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 			fail();
 		} catch(NoSuchContactException expected) {}
 
+		try {
+			db.getContact(contactId);
+			fail();
+		} catch(NoSuchContactException expected) {}
+
 		try {
 			db.getVisibleSubscriptions(contactId);
 			fail();
@@ -660,6 +667,53 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 		context.assertIsSatisfied();
 	}
 
+	@Test
+	public void testVariousMethodsThrowExceptionIfSubscriptionIsMissing()
+			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() {{
+			// Check whether the subscription is in the DB (which it's not)
+			exactly(5).of(database).startTransaction();
+			will(returnValue(txn));
+			exactly(5).of(database).containsTransport(txn, transportId);
+			will(returnValue(false));
+			exactly(5).of(database).abortTransaction(txn);
+		}});
+		DatabaseComponent db = createDatabaseComponent(database, cleaner,
+				shutdown);
+
+		try {
+			db.getGroup(groupId);
+			fail();
+		} catch(NoSuchSubscriptionException expected) {}
+
+		try {
+			db.getMessageHeaders(groupId);
+			fail();
+		} catch(NoSuchSubscriptionException expected) {}
+
+		try {
+			db.getVisibility(groupId);
+			fail();
+		} catch(NoSuchSubscriptionException expected) {}
+
+		try {
+			db.setVisibility(groupId, Collections.<ContactId>emptyList());
+			fail();
+		} catch(NoSuchSubscriptionException expected) {}
+
+		try {
+			db.unsubscribe(groupId);
+			fail();
+		} catch(NoSuchSubscriptionException expected) {}
+
+		context.assertIsSatisfied();
+	}
+
 	@Test
 	public void testVariousMethodsThrowExceptionIfTransportIsMissing()
 			throws Exception {
@@ -1454,7 +1508,8 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 			oneOf(database).setSendability(txn, messageId, 0);
 			oneOf(database).commitTransaction(txn);
 			// The message was added, so the listener should be called
-			oneOf(listener).eventOccurred(with(any(MessageAddedEvent.class)));
+			oneOf(listener).eventOccurred(with(any(
+					GroupMessageAddedEvent.class)));
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
@@ -1485,7 +1540,8 @@ public abstract class DatabaseComponentTest extends BriarTestCase {
 			oneOf(database).setReadFlag(txn, messageId, true);
 			oneOf(database).addStatus(txn, contactId, messageId, false);
 			// The message was added, so the listener should be called
-			oneOf(listener).eventOccurred(with(any(MessageAddedEvent.class)));
+			oneOf(listener).eventOccurred(with(any(
+					PrivateMessageAddedEvent.class)));
 		}});
 		DatabaseComponent db = createDatabaseComponent(database, cleaner,
 				shutdown);
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 105518b8f5..b87285eb61 100644
--- a/briar-tests/src/net/sf/briar/messaging/simplex/SimplexMessagingIntegrationTest.java
+++ b/briar-tests/src/net/sf/briar/messaging/simplex/SimplexMessagingIntegrationTest.java
@@ -15,7 +15,7 @@ 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.MessageAddedEvent;
+import net.sf.briar.api.db.event.PrivateMessageAddedEvent;
 import net.sf.briar.api.messaging.Message;
 import net.sf.briar.api.messaging.MessageFactory;
 import net.sf.briar.api.messaging.MessageVerifier;
@@ -186,14 +186,14 @@ public class SimplexMessagingIntegrationTest extends BriarTestCase {
 				messageVerifier, db, connRegistry, connWriterFactory,
 				packetWriterFactory, ctx, transport);
 		// No messages should have been added yet
-		assertFalse(listener.messagesAdded);
+		assertFalse(listener.messageAdded);
 		// Read whatever needs to be read
 		simplex.read();
 		assertTrue(transport.getDisposed());
 		assertFalse(transport.getException());
 		assertTrue(transport.getRecognised());
 		// The private message from Alice should have been added
-		assertTrue(listener.messagesAdded);
+		assertTrue(listener.messageAdded);
 		// Clean up
 		km.stop();
 		db.close();
@@ -206,10 +206,10 @@ public class SimplexMessagingIntegrationTest extends BriarTestCase {
 
 	private static class MessageListener implements DatabaseListener {
 
-		private boolean messagesAdded = false;
+		private boolean messageAdded = false;
 
 		public void eventOccurred(DatabaseEvent e) {
-			if(e instanceof MessageAddedEvent) messagesAdded = true;
+			if(e instanceof PrivateMessageAddedEvent) messageAdded = true;
 		}
 	}
 }
-- 
GitLab