From a727a0817e0981146eff43118dfbb53c06b7f89a Mon Sep 17 00:00:00 2001
From: Torsten Grote <t@grobox.de>
Date: Tue, 4 Oct 2016 13:37:29 -0300
Subject: [PATCH] Store message count, unread count and timestamp of latest
 message

in group metadata to be able to speed up group listings.

Closes #584, #586, #585
---
 .../BlogSharingIntegrationTest.java           |  36 ++++--
 .../briarproject/BriarIntegrationTest.java    |  32 +++++
 .../org/briarproject/ForumManagerTest.java    |  26 +++-
 .../IntroductionIntegrationTest.java          |  22 +++-
 .../android/contact/ConversationActivity.java |   4 +-
 .../android/forum/ForumControllerImpl.java    |   4 +-
 .../api/clients/MessageTracker.java           |  44 +++++++
 .../api/forum/ForumConstants.java             |   1 -
 .../briarproject/api/forum/ForumManager.java  |   6 +-
 .../introduction/IntroductionConstants.java   |   1 -
 .../api/introduction/IntroductionManager.java |   3 +-
 .../api/messaging/MessagingManager.java       |   5 +-
 .../api/privategroup/PrivateGroupManager.java |   6 +-
 .../api/sharing/SharingConstants.java         |   1 -
 .../api/sharing/SharingManager.java           |   3 +-
 .../briarproject/blogs/BlogManagerImpl.java   |   4 +-
 .../briarproject/clients/BdfConstants.java    |  11 ++
 .../clients/BdfIncomingMessageHook.java       | 115 ++++++++++++++++-
 .../briarproject/forum/ForumManagerImpl.java  |  30 ++---
 .../introduction/IntroductionManagerImpl.java |  18 ++-
 .../messaging/MessagingManagerImpl.java       |  27 ++--
 .../messaging/PrivateMessageValidator.java    |   3 +-
 .../privategroup/PrivateGroupManagerImpl.java |  26 ++--
 .../sharing/SharingManagerImpl.java           |  18 ++-
 .../IntroductionManagerImplTest.java          | 116 +++++++++++++-----
 25 files changed, 421 insertions(+), 141 deletions(-)
 create mode 100644 briar-android-tests/src/test/java/org/briarproject/BriarIntegrationTest.java
 create mode 100644 briar-api/src/org/briarproject/api/clients/MessageTracker.java
 create mode 100644 briar-core/src/org/briarproject/clients/BdfConstants.java

diff --git a/briar-android-tests/src/test/java/org/briarproject/BlogSharingIntegrationTest.java b/briar-android-tests/src/test/java/org/briarproject/BlogSharingIntegrationTest.java
index 70afe99efd..bca45b25f8 100644
--- a/briar-android-tests/src/test/java/org/briarproject/BlogSharingIntegrationTest.java
+++ b/briar-android-tests/src/test/java/org/briarproject/BlogSharingIntegrationTest.java
@@ -8,6 +8,7 @@ import org.briarproject.api.blogs.BlogInvitationResponse;
 import org.briarproject.api.blogs.BlogManager;
 import org.briarproject.api.blogs.BlogPostFactory;
 import org.briarproject.api.blogs.BlogSharingManager;
+import org.briarproject.api.clients.ContactGroupFactory;
 import org.briarproject.api.contact.Contact;
 import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.contact.ContactManager;
@@ -25,6 +26,7 @@ import org.briarproject.api.identity.IdentityManager;
 import org.briarproject.api.identity.LocalAuthor;
 import org.briarproject.api.lifecycle.LifecycleManager;
 import org.briarproject.api.sharing.InvitationMessage;
+import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.SyncSession;
 import org.briarproject.api.sync.SyncSessionFactory;
 import org.briarproject.api.sync.ValidationManager.State;
@@ -62,12 +64,12 @@ import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
-public class BlogSharingIntegrationTest extends BriarTestCase {
+public class BlogSharingIntegrationTest extends BriarIntegrationTest {
 
 	private LifecycleManager lifecycleManager0, lifecycleManager1,
 			lifecycleManager2;
-	private SyncSessionFactory sync0, sync1, sync2;
-	private BlogManager blogManager0, blogManager1, blogManager2;
+	private SyncSessionFactory sync0, sync1;
+	private BlogManager blogManager0, blogManager1;
 	private ContactManager contactManager0, contactManager1, contactManager2;
 	private Contact contact1, contact2, contact01, contact02;
 	private ContactId contactId1, contactId2, contactId01, contactId02;
@@ -83,6 +85,8 @@ public class BlogSharingIntegrationTest extends BriarTestCase {
 	@Inject
 	AuthorFactory authorFactory;
 	@Inject
+	ContactGroupFactory contactGroupFactory;
+	@Inject
 	BlogPostFactory blogPostFactory;
 	@Inject
 	CryptoComponent cryptoComponent;
@@ -138,13 +142,11 @@ public class BlogSharingIntegrationTest extends BriarTestCase {
 		contactManager2 = t2.getContactManager();
 		blogManager0 = t0.getBlogManager();
 		blogManager1 = t1.getBlogManager();
-		blogManager2 = t2.getBlogManager();
 		blogSharingManager0 = t0.getBlogSharingManager();
 		blogSharingManager1 = t1.getBlogSharingManager();
 		blogSharingManager2 = t2.getBlogSharingManager();
 		sync0 = t0.getSyncSessionFactory();
 		sync1 = t1.getSyncSessionFactory();
-		sync2 = t2.getSyncSessionFactory();
 
 		// initialize waiters fresh for each test
 		eventWaiter = new Waiter();
@@ -187,15 +189,23 @@ public class BlogSharingIntegrationTest extends BriarTestCase {
 		// invitee has own blog and that of the sharer
 		assertEquals(2, blogManager1.getBlogs().size());
 
+		// get sharing group and assert group message count
+		GroupId g = contactGroupFactory
+				.createContactGroup(blogSharingManager0.getClientId(),
+						contact1).getId();
+		assertGroupCount(blogSharingManager0, g, 1, 0);
+
 		// sync first request message
 		sync0To1();
 		eventWaiter.await(TIMEOUT, 1);
 		assertTrue(listener1.requestReceived);
+		assertGroupCount(blogSharingManager1, g, 2, 1);
 
 		// sync response back
 		sync1To0();
 		eventWaiter.await(TIMEOUT, 1);
 		assertTrue(listener0.responseReceived);
+		assertGroupCount(blogSharingManager0, g, 2, 1);
 
 		// blog was added successfully
 		assertEquals(0, blogSharingManager0.getInvitations().size());
@@ -232,6 +242,10 @@ public class BlogSharingIntegrationTest extends BriarTestCase {
 		assertFalse(blogSharingManager0.canBeShared(blog2.getId(), contact1));
 		assertFalse(blogSharingManager1.canBeShared(blog2.getId(), contact01));
 
+		// group message count is still correct
+		assertGroupCount(blogSharingManager0, g, 2, 1);
+		assertGroupCount(blogSharingManager1, g, 2, 1);
+
 		stopLifecycles();
 	}
 
@@ -510,8 +524,7 @@ public class BlogSharingIntegrationTest extends BriarTestCase {
 
 	private class SharerListener implements EventListener {
 
-		volatile boolean requestReceived = false;
-		volatile boolean responseReceived = false;
+		private volatile boolean responseReceived = false;
 
 		@Override
 		public void eventOccurred(Event e) {
@@ -534,7 +547,6 @@ public class BlogSharingIntegrationTest extends BriarTestCase {
 				BlogInvitationReceivedEvent event =
 						(BlogInvitationReceivedEvent) e;
 				eventWaiter.assertEquals(contactId1, event.getContactId());
-				requestReceived = true;
 				Blog b = event.getBlog();
 				try {
 					Contact c = contactManager0.getContact(contactId1);
@@ -550,17 +562,16 @@ public class BlogSharingIntegrationTest extends BriarTestCase {
 
 	private class InviteeListener implements EventListener {
 
-		volatile boolean requestReceived = false;
-		volatile boolean responseReceived = false;
+		private volatile boolean requestReceived = false;
 
 		private final boolean accept, answer;
 
-		InviteeListener(boolean accept, boolean answer) {
+		private InviteeListener(boolean accept, boolean answer) {
 			this.accept = accept;
 			this.answer = answer;
 		}
 
-		InviteeListener(boolean accept) {
+		private InviteeListener(boolean accept) {
 			this(accept, true);
 		}
 
@@ -596,7 +607,6 @@ public class BlogSharingIntegrationTest extends BriarTestCase {
 				BlogInvitationResponseReceivedEvent event =
 						(BlogInvitationResponseReceivedEvent) e;
 				eventWaiter.assertEquals(contactId01, event.getContactId());
-				responseReceived = true;
 				eventWaiter.resume();
 			}
 		}
diff --git a/briar-android-tests/src/test/java/org/briarproject/BriarIntegrationTest.java b/briar-android-tests/src/test/java/org/briarproject/BriarIntegrationTest.java
new file mode 100644
index 0000000000..f1b2a09143
--- /dev/null
+++ b/briar-android-tests/src/test/java/org/briarproject/BriarIntegrationTest.java
@@ -0,0 +1,32 @@
+package org.briarproject;
+
+import org.briarproject.api.clients.MessageTracker;
+import org.briarproject.api.clients.MessageTracker.GroupCount;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.sync.GroupId;
+
+import static org.junit.Assert.assertEquals;
+
+public abstract class BriarIntegrationTest extends BriarTestCase {
+
+	// TODO maybe we could add uncaught exception handlers for other threads here (#670)
+
+	protected void assertGroupCount(MessageTracker tracker, GroupId g,
+			long msgCount, long unreadCount, long latestMsg)
+			throws DbException {
+
+		GroupCount groupCount = tracker.getGroupCount(g);
+		assertEquals(msgCount, groupCount.getMsgCount());
+		assertEquals(unreadCount, groupCount.getUnreadCount());
+		assertEquals(latestMsg, groupCount.getLatestMsgTime());
+	}
+
+	protected void assertGroupCount(MessageTracker tracker, GroupId g,
+			long msgCount, long unreadCount) throws	DbException {
+
+		GroupCount c1 = tracker.getGroupCount(g);
+		assertEquals(msgCount, c1.getMsgCount());
+		assertEquals(unreadCount, c1.getUnreadCount());
+	}
+
+}
diff --git a/briar-android-tests/src/test/java/org/briarproject/ForumManagerTest.java b/briar-android-tests/src/test/java/org/briarproject/ForumManagerTest.java
index 0fa8e80c1e..64ad251f07 100644
--- a/briar-android-tests/src/test/java/org/briarproject/ForumManagerTest.java
+++ b/briar-android-tests/src/test/java/org/briarproject/ForumManagerTest.java
@@ -59,7 +59,7 @@ import static org.briarproject.api.sync.ValidationManager.State.INVALID;
 import static org.briarproject.api.sync.ValidationManager.State.PENDING;
 import static org.junit.Assert.assertTrue;
 
-public class ForumManagerTest {
+public class ForumManagerTest extends BriarIntegrationTest {
 
 	private LifecycleManager lifecycleManager0, lifecycleManager1;
 	private SyncSessionFactory sync0, sync1;
@@ -150,9 +150,19 @@ public class ForumManagerTest {
 				createForumPost(forum.getGroup().getId(), post1, body2, ms2);
 		assertEquals(ms2, post2.getMessage().getTimestamp());
 		forumManager0.addLocalPost(post1);
-		forumManager0.setReadFlag(post1.getMessage().getId(), true);
+		forumManager0.setReadFlag(forum.getGroup().getId(),
+				post1.getMessage().getId(), true);
+		assertGroupCount(forumManager0, forum.getGroup().getId(), 1, 0,
+				post1.getMessage().getTimestamp());
 		forumManager0.addLocalPost(post2);
-		forumManager0.setReadFlag(post2.getMessage().getId(), false);
+		forumManager0.setReadFlag(forum.getGroup().getId(),
+				post2.getMessage().getId(), false);
+		assertGroupCount(forumManager0, forum.getGroup().getId(), 2, 1,
+				post2.getMessage().getTimestamp());
+		forumManager0.setReadFlag(forum.getGroup().getId(),
+				post2.getMessage().getId(), false);
+		assertGroupCount(forumManager0, forum.getGroup().getId(), 2, 1,
+				post2.getMessage().getTimestamp());
 		Collection<ForumPostHeader> headers =
 				forumManager0.getPostHeaders(forum.getGroup().getId());
 		assertEquals(2, headers.size());
@@ -202,23 +212,29 @@ public class ForumManagerTest {
 		forumManager0.addLocalPost(post1);
 		assertEquals(1, forumManager0.getPostHeaders(g).size());
 		assertEquals(0, forumManager1.getPostHeaders(g).size());
+		assertGroupCount(forumManager0, g, 1, 0, time);
+		assertGroupCount(forumManager1, g, 0, 0, 0);
 
 		// send post to 1
 		sync0To1();
 		deliveryWaiter.await(TIMEOUT, 1);
 		assertEquals(1, forumManager1.getPostHeaders(g).size());
+		assertGroupCount(forumManager1, g, 1, 1, time);
 
 		// add another forum post
-		time = clock.currentTimeMillis();
-		ForumPost post2 = createForumPost(g, null, "b", time);
+		long time2 = clock.currentTimeMillis();
+		ForumPost post2 = createForumPost(g, null, "b", time2);
 		forumManager1.addLocalPost(post2);
 		assertEquals(1, forumManager0.getPostHeaders(g).size());
 		assertEquals(2, forumManager1.getPostHeaders(g).size());
+		assertGroupCount(forumManager0, g, 1, 0, time);
+		assertGroupCount(forumManager1, g, 2, 1, time2);
 
 		// send post to 0
 		sync1To0();
 		deliveryWaiter.await(TIMEOUT, 1);
 		assertEquals(2, forumManager1.getPostHeaders(g).size());
+		assertGroupCount(forumManager0, g, 2, 1, time2);
 
 		stopLifecycles();
 	}
diff --git a/briar-android-tests/src/test/java/org/briarproject/introduction/IntroductionIntegrationTest.java b/briar-android-tests/src/test/java/org/briarproject/introduction/IntroductionIntegrationTest.java
index 364040e7ce..e9c4487a4e 100644
--- a/briar-android-tests/src/test/java/org/briarproject/introduction/IntroductionIntegrationTest.java
+++ b/briar-android-tests/src/test/java/org/briarproject/introduction/IntroductionIntegrationTest.java
@@ -4,7 +4,7 @@ import android.support.annotation.Nullable;
 
 import net.jodah.concurrentunit.Waiter;
 
-import org.briarproject.BriarTestCase;
+import org.briarproject.BriarIntegrationTest;
 import org.briarproject.TestDatabaseModule;
 import org.briarproject.TestUtils;
 import org.briarproject.api.FormatException;
@@ -99,7 +99,7 @@ import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
-public class IntroductionIntegrationTest extends BriarTestCase {
+public class IntroductionIntegrationTest extends BriarIntegrationTest {
 
 	private LifecycleManager lifecycleManager0, lifecycleManager1,
 			lifecycleManager2;
@@ -200,29 +200,43 @@ public class IntroductionIntegrationTest extends BriarTestCase {
 			introductionManager0
 					.makeIntroduction(introducee1, introducee2, "Hi!", time);
 
+			// check that messages are tracked properly
+			Group g1 = introductionGroupFactory
+					.createIntroductionGroup(introducee1);
+			Group g2 = introductionGroupFactory
+					.createIntroductionGroup(introducee2);
+			assertGroupCount(introductionManager0, g1.getId(), 1, 0, time);
+			assertGroupCount(introductionManager0, g2.getId(), 1, 0, time);
+
 			// sync first request message
 			deliverMessage(sync0, contactId0, sync1, contactId1, "0 to 1");
 			eventWaiter.await(TIMEOUT, 1);
 			assertTrue(listener1.requestReceived);
+			assertGroupCount(introductionManager1, g1.getId(), 2, 1);
 
 			// sync second request message
 			deliverMessage(sync0, contactId0, sync2, contactId2, "0 to 2");
 			eventWaiter.await(TIMEOUT, 1);
 			assertTrue(listener2.requestReceived);
+			assertGroupCount(introductionManager2, g2.getId(), 2, 1);
 
 			// sync first response
 			deliverMessage(sync1, contactId1, sync0, contactId0, "1 to 0");
 			eventWaiter.await(TIMEOUT, 1);
 			assertTrue(listener0.response1Received);
+			assertGroupCount(introductionManager0, g1.getId(), 2, 1);
 
 			// sync second response
 			deliverMessage(sync2, contactId2, sync0, contactId0, "2 to 0");
 			eventWaiter.await(TIMEOUT, 1);
 			assertTrue(listener0.response2Received);
+			assertGroupCount(introductionManager0, g2.getId(), 2, 1);
 
 			// sync forwarded responses to introducees
 			deliverMessage(sync0, contactId0, sync1, contactId1, "0 to 1");
 			deliverMessage(sync0, contactId0, sync2, contactId2, "0 to 2");
+			assertGroupCount(introductionManager1, g1.getId(), 3, 2);
+			assertGroupCount(introductionManager2, g2.getId(), 3, 2);
 
 			// sync first ACK and its forward
 			deliverMessage(sync1, contactId1, sync0, contactId0, "1 to 0");
@@ -255,6 +269,10 @@ public class IntroductionIntegrationTest extends BriarTestCase {
 			}
 
 			assertDefaultUiMessages();
+			assertGroupCount(introductionManager0, g1.getId(), 2, 1);
+			assertGroupCount(introductionManager0, g2.getId(), 2, 1);
+			assertGroupCount(introductionManager1, g1.getId(), 3, 2);
+			assertGroupCount(introductionManager2, g2.getId(), 3, 2);
 		} finally {
 			stopLifecycles();
 		}
diff --git a/briar-android/src/org/briarproject/android/contact/ConversationActivity.java b/briar-android/src/org/briarproject/android/contact/ConversationActivity.java
index 7ec442b5b1..722e6b8588 100644
--- a/briar-android/src/org/briarproject/android/contact/ConversationActivity.java
+++ b/briar-android/src/org/briarproject/android/contact/ConversationActivity.java
@@ -499,7 +499,7 @@ public class ConversationActivity extends BriarActivity
 					for (MessageId m : unread)
 						// not really clean, but the messaging manager can
 						// handle introduction messages as well
-						messagingManager.setReadFlag(m, true);
+						messagingManager.setReadFlag(groupId, m, true);
 					long duration = System.currentTimeMillis() - now;
 					if (LOG.isLoggable(INFO))
 						LOG.info("Marking read took " + duration + " ms");
@@ -614,7 +614,7 @@ public class ConversationActivity extends BriarActivity
 			@Override
 			public void run() {
 				try {
-					messagingManager.setReadFlag(m, true);
+					messagingManager.setReadFlag(groupId, m, true);
 					loadMessages();
 				} catch (DbException e) {
 					if (LOG.isLoggable(WARNING))
diff --git a/briar-android/src/org/briarproject/android/forum/ForumControllerImpl.java b/briar-android/src/org/briarproject/android/forum/ForumControllerImpl.java
index bf147491b1..98f7ac139e 100644
--- a/briar-android/src/org/briarproject/android/forum/ForumControllerImpl.java
+++ b/briar-android/src/org/briarproject/android/forum/ForumControllerImpl.java
@@ -291,13 +291,15 @@ public class ForumControllerImpl extends DbControllerImpl
 
 	@Override
 	public void entriesRead(final Collection<ForumEntry> forumEntries) {
+		if (forum == null) return;
 		runOnDbThread(new Runnable() {
 			@Override
 			public void run() {
 				try {
 					long now = System.currentTimeMillis();
 					for (ForumEntry fe : forumEntries) {
-						forumManager.setReadFlag(fe.getId(), true);
+						forumManager
+								.setReadFlag(forum.getId(), fe.getId(), true);
 					}
 					long duration = System.currentTimeMillis() - now;
 					if (LOG.isLoggable(INFO))
diff --git a/briar-api/src/org/briarproject/api/clients/MessageTracker.java b/briar-api/src/org/briarproject/api/clients/MessageTracker.java
new file mode 100644
index 0000000000..8226a3058c
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/clients/MessageTracker.java
@@ -0,0 +1,44 @@
+package org.briarproject.api.clients;
+
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.Transaction;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.MessageId;
+
+public interface MessageTracker {
+
+	/**
+	 * Gets the number of visible and unread messages in the group
+	 * as well as the timestamp of the latest message
+	 **/
+	GroupCount getGroupCount(GroupId g) throws DbException;
+
+	/**
+	 * Marks a message as read or unread and updates the group counts in g.
+	 **/
+	void setReadFlag(GroupId g, MessageId m, boolean read) throws DbException;
+
+	class GroupCount {
+		private final long msgCount, unreadCount, latestMsgTime;
+
+		public GroupCount(long msgCount, long unreadCount, long latestMsgTime) {
+			this.msgCount = msgCount;
+			this.unreadCount = unreadCount;
+			this.latestMsgTime = latestMsgTime;
+		}
+
+		public long getMsgCount() {
+			return msgCount;
+		}
+
+		public long getUnreadCount() {
+			return unreadCount;
+		}
+
+		public long getLatestMsgTime() {
+			return latestMsgTime;
+		}
+	}
+
+}
diff --git a/briar-api/src/org/briarproject/api/forum/ForumConstants.java b/briar-api/src/org/briarproject/api/forum/ForumConstants.java
index 97557d5fe4..048f6c3c71 100644
--- a/briar-api/src/org/briarproject/api/forum/ForumConstants.java
+++ b/briar-api/src/org/briarproject/api/forum/ForumConstants.java
@@ -28,6 +28,5 @@ public interface ForumConstants {
 	String KEY_PUBLIC_NAME = "publicKey";
 	String KEY_AUTHOR = "author";
 	String KEY_LOCAL = "local";
-	String KEY_READ = "read";
 
 }
diff --git a/briar-api/src/org/briarproject/api/forum/ForumManager.java b/briar-api/src/org/briarproject/api/forum/ForumManager.java
index 8a1c0e7c4e..8c35d05e36 100644
--- a/briar-api/src/org/briarproject/api/forum/ForumManager.java
+++ b/briar-api/src/org/briarproject/api/forum/ForumManager.java
@@ -1,5 +1,6 @@
 package org.briarproject.api.forum;
 
+import org.briarproject.api.clients.MessageTracker;
 import org.briarproject.api.db.DbException;
 import org.briarproject.api.db.Transaction;
 import org.briarproject.api.sync.ClientId;
@@ -8,7 +9,7 @@ import org.briarproject.api.sync.MessageId;
 
 import java.util.Collection;
 
-public interface ForumManager {
+public interface ForumManager extends MessageTracker {
 
 	/** Returns the unique ID of the forum client. */
 	ClientId getClientId();
@@ -37,9 +38,6 @@ public interface ForumManager {
 	/** Returns the headers of all posts in the given forum. */
 	Collection<ForumPostHeader> getPostHeaders(GroupId g) throws DbException;
 
-	/** Marks a forum post as read or unread. */
-	void setReadFlag(MessageId m, boolean read) throws DbException;
-
 	/** Registers a hook to be called whenever a forum is removed. */
 	void registerRemoveForumHook(RemoveForumHook hook);
 
diff --git a/briar-api/src/org/briarproject/api/introduction/IntroductionConstants.java b/briar-api/src/org/briarproject/api/introduction/IntroductionConstants.java
index ac8975e5c2..de74b390ff 100644
--- a/briar-api/src/org/briarproject/api/introduction/IntroductionConstants.java
+++ b/briar-api/src/org/briarproject/api/introduction/IntroductionConstants.java
@@ -45,7 +45,6 @@ public interface IntroductionConstants {
 	String CONTACT_ID_2 = "contactId2";
 	String RESPONSE_1 = "response1";
 	String RESPONSE_2 = "response2";
-	String READ = "read";
 
 	/* Introduction Request Action */
 	String PUBLIC_KEY1 = "publicKey1";
diff --git a/briar-api/src/org/briarproject/api/introduction/IntroductionManager.java b/briar-api/src/org/briarproject/api/introduction/IntroductionManager.java
index 9819c87580..3f2fcea12f 100644
--- a/briar-api/src/org/briarproject/api/introduction/IntroductionManager.java
+++ b/briar-api/src/org/briarproject/api/introduction/IntroductionManager.java
@@ -1,6 +1,7 @@
 package org.briarproject.api.introduction;
 
 import org.briarproject.api.FormatException;
+import org.briarproject.api.clients.MessageTracker;
 import org.briarproject.api.clients.SessionId;
 import org.briarproject.api.contact.Contact;
 import org.briarproject.api.contact.ContactId;
@@ -9,7 +10,7 @@ import org.briarproject.api.sync.ClientId;
 
 import java.util.Collection;
 
-public interface IntroductionManager {
+public interface IntroductionManager extends MessageTracker {
 
 	/** Returns the unique ID of the introduction client. */
 	ClientId getClientId();
diff --git a/briar-api/src/org/briarproject/api/messaging/MessagingManager.java b/briar-api/src/org/briarproject/api/messaging/MessagingManager.java
index f22a5131af..627f5ceb6d 100644
--- a/briar-api/src/org/briarproject/api/messaging/MessagingManager.java
+++ b/briar-api/src/org/briarproject/api/messaging/MessagingManager.java
@@ -1,5 +1,6 @@
 package org.briarproject.api.messaging;
 
+import org.briarproject.api.clients.MessageTracker;
 import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.db.DbException;
 import org.briarproject.api.sync.ClientId;
@@ -8,7 +9,7 @@ import org.briarproject.api.sync.MessageId;
 
 import java.util.Collection;
 
-public interface MessagingManager {
+public interface MessagingManager extends MessageTracker {
 
 	/** Returns the unique ID of the messaging client. */
 	ClientId getClientId();
@@ -31,6 +32,4 @@ public interface MessagingManager {
 	/** Returns the body of the private message with the given ID. */
 	byte[] getMessageBody(MessageId m) throws DbException;
 
-	/** Marks a private message as read or unread. */
-	void setReadFlag(MessageId m, boolean read) throws DbException;
 }
diff --git a/briar-api/src/org/briarproject/api/privategroup/PrivateGroupManager.java b/briar-api/src/org/briarproject/api/privategroup/PrivateGroupManager.java
index 3f2a637624..ab02e86bc0 100644
--- a/briar-api/src/org/briarproject/api/privategroup/PrivateGroupManager.java
+++ b/briar-api/src/org/briarproject/api/privategroup/PrivateGroupManager.java
@@ -1,5 +1,6 @@
 package org.briarproject.api.privategroup;
 
+import org.briarproject.api.clients.MessageTracker;
 import org.briarproject.api.db.DbException;
 import org.briarproject.api.db.Transaction;
 import org.briarproject.api.sync.ClientId;
@@ -9,7 +10,7 @@ import org.jetbrains.annotations.NotNull;
 
 import java.util.Collection;
 
-public interface PrivateGroupManager {
+public interface PrivateGroupManager extends MessageTracker {
 
 	/** Returns the unique ID of the private group client. */
 	@NotNull
@@ -40,7 +41,4 @@ public interface PrivateGroupManager {
 	@NotNull
 	Collection<GroupMessageHeader> getHeaders(GroupId g) throws DbException;
 
-	/** Marks a group message as read or unread. */
-	void setReadFlag(MessageId m, boolean read) throws DbException;
-
 }
diff --git a/briar-api/src/org/briarproject/api/sharing/SharingConstants.java b/briar-api/src/org/briarproject/api/sharing/SharingConstants.java
index 43d689729b..e09b9015c5 100644
--- a/briar-api/src/org/briarproject/api/sharing/SharingConstants.java
+++ b/briar-api/src/org/briarproject/api/sharing/SharingConstants.java
@@ -16,7 +16,6 @@ public interface SharingConstants {
 	String STATE = "state";
 	String LOCAL = "local";
 	String TIME = "time";
-	String READ = "read";
 	String IS_SHARER = "isSharer";
 	String SHAREABLE_ID = "shareableId";
 	String INVITATION_MSG = "invitationMsg";
diff --git a/briar-api/src/org/briarproject/api/sharing/SharingManager.java b/briar-api/src/org/briarproject/api/sharing/SharingManager.java
index 0a66a54588..c633256f12 100644
--- a/briar-api/src/org/briarproject/api/sharing/SharingManager.java
+++ b/briar-api/src/org/briarproject/api/sharing/SharingManager.java
@@ -1,5 +1,6 @@
 package org.briarproject.api.sharing;
 
+import org.briarproject.api.clients.MessageTracker;
 import org.briarproject.api.contact.Contact;
 import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.db.DbException;
@@ -8,7 +9,7 @@ import org.briarproject.api.sync.GroupId;
 
 import java.util.Collection;
 
-public interface SharingManager<S extends Shareable> {
+public interface SharingManager<S extends Shareable> extends MessageTracker {
 
 	/** Returns the unique ID of the group sharing client. */
 	ClientId getClientId();
diff --git a/briar-core/src/org/briarproject/blogs/BlogManagerImpl.java b/briar-core/src/org/briarproject/blogs/BlogManagerImpl.java
index 094917991b..d31023c447 100644
--- a/briar-core/src/org/briarproject/blogs/BlogManagerImpl.java
+++ b/briar-core/src/org/briarproject/blogs/BlogManagerImpl.java
@@ -86,7 +86,6 @@ class BlogManagerImpl extends BdfIncomingMessageHook implements BlogManager,
 			"dafbe56f0c8971365cea4bb5f08ec9a6" +
 					"1d686e058b943997b6ff259ba423f613"));
 
-	private final DatabaseComponent db;
 	private final IdentityManager identityManager;
 	private final ContactManager contactManager;
 	private final BlogFactory blogFactory;
@@ -98,9 +97,8 @@ class BlogManagerImpl extends BdfIncomingMessageHook implements BlogManager,
 			ClientHelper clientHelper, MetadataParser metadataParser,
 			ContactManager contactManager, BlogFactory blogFactory,
 			BlogPostFactory blogPostFactory) {
-		super(clientHelper, metadataParser);
+		super(db, clientHelper, metadataParser);
 
-		this.db = db;
 		this.identityManager = identityManager;
 		this.contactManager = contactManager;
 		this.blogFactory = blogFactory;
diff --git a/briar-core/src/org/briarproject/clients/BdfConstants.java b/briar-core/src/org/briarproject/clients/BdfConstants.java
new file mode 100644
index 0000000000..6f81491854
--- /dev/null
+++ b/briar-core/src/org/briarproject/clients/BdfConstants.java
@@ -0,0 +1,11 @@
+package org.briarproject.clients;
+
+public interface BdfConstants {
+
+	String GROUP_KEY_MSG_COUNT = "messageCount";
+	String GROUP_KEY_UNREAD_COUNT = "unreadCount";
+	String GROUP_KEY_LATEST_MSG = "latestMessageTime";
+
+	String MSG_KEY_READ = "read";
+
+}
diff --git a/briar-core/src/org/briarproject/clients/BdfIncomingMessageHook.java b/briar-core/src/org/briarproject/clients/BdfIncomingMessageHook.java
index 3ca19d9bd5..c81293ab04 100644
--- a/briar-core/src/org/briarproject/clients/BdfIncomingMessageHook.java
+++ b/briar-core/src/org/briarproject/clients/BdfIncomingMessageHook.java
@@ -3,27 +3,38 @@ package org.briarproject.clients;
 import org.briarproject.api.FormatException;
 import org.briarproject.api.clients.ClientHelper;
 import org.briarproject.api.clients.MessageQueueManager.IncomingQueueMessageHook;
+import org.briarproject.api.clients.MessageTracker;
 import org.briarproject.api.clients.QueueMessage;
 import org.briarproject.api.data.BdfDictionary;
+import org.briarproject.api.data.BdfEntry;
 import org.briarproject.api.data.BdfList;
 import org.briarproject.api.data.MetadataParser;
+import org.briarproject.api.db.DatabaseComponent;
 import org.briarproject.api.db.DbException;
 import org.briarproject.api.db.Metadata;
 import org.briarproject.api.db.Transaction;
+import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.MessageId;
 import org.briarproject.api.sync.ValidationManager.IncomingMessageHook;
 
 import static org.briarproject.api.clients.QueueMessage.QUEUE_MESSAGE_HEADER_LENGTH;
 import static org.briarproject.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
+import static org.briarproject.clients.BdfConstants.GROUP_KEY_LATEST_MSG;
+import static org.briarproject.clients.BdfConstants.GROUP_KEY_MSG_COUNT;
+import static org.briarproject.clients.BdfConstants.GROUP_KEY_UNREAD_COUNT;
+import static org.briarproject.clients.BdfConstants.MSG_KEY_READ;
 
 public abstract class BdfIncomingMessageHook implements IncomingMessageHook,
-		IncomingQueueMessageHook {
+		IncomingQueueMessageHook, MessageTracker {
 
+	protected final DatabaseComponent db;
 	protected final ClientHelper clientHelper;
 	protected final MetadataParser metadataParser;
 
-	protected BdfIncomingMessageHook(ClientHelper clientHelper,
-			MetadataParser metadataParser) {
+	protected BdfIncomingMessageHook(DatabaseComponent db,
+			ClientHelper clientHelper, MetadataParser metadataParser) {
+		this.db = db;
 		this.clientHelper = clientHelper;
 		this.metadataParser = metadataParser;
 	}
@@ -56,4 +67,102 @@ public abstract class BdfIncomingMessageHook implements IncomingMessageHook,
 			throw new DbException(e);
 		}
 	}
+
+	protected void trackIncomingMessage(Transaction txn, Message m)
+			throws DbException {
+		trackMessage(txn, m.getGroupId(), m.getTimestamp(), false);
+	}
+
+	protected void trackOutgoingMessage(Transaction txn, Message m)
+			throws DbException {
+		trackMessage(txn, m.getGroupId(), m.getTimestamp(), true);
+	}
+
+	protected void trackMessage(Transaction txn, GroupId g, long time,
+			boolean read) throws DbException {
+		GroupCount c = getGroupCount(txn, g);
+		long msgCount = c.getMsgCount() + 1;
+		long unreadCount = c.getUnreadCount() + (read ? 0 : 1);
+		long latestTime =
+				time > c.getLatestMsgTime() ? time : c.getLatestMsgTime();
+		storeGroupCount(txn, g,
+				new GroupCount(msgCount, unreadCount, latestTime));
+	}
+
+	@Override
+	public GroupCount getGroupCount(GroupId g) throws DbException {
+		GroupCount count;
+		Transaction txn = db.startTransaction(true);
+		try {
+			count = getGroupCount(txn, g);
+			txn.setComplete();
+		}
+		finally {
+			db.endTransaction(txn);
+		}
+		return count;
+	}
+
+	private GroupCount getGroupCount(Transaction txn, GroupId g)
+			throws DbException {
+		GroupCount count;
+		try {
+			BdfDictionary d = clientHelper.getGroupMetadataAsDictionary(txn, g);
+			count = new GroupCount(
+					d.getLong(GROUP_KEY_MSG_COUNT, 0L),
+					d.getLong(GROUP_KEY_UNREAD_COUNT, 0L),
+					d.getLong(GROUP_KEY_LATEST_MSG, 0L)
+			);
+		} catch (FormatException e) {
+			throw new DbException(e);
+		}
+		return count;
+	}
+
+	private void storeGroupCount(Transaction txn, GroupId g, GroupCount c)
+			throws DbException{
+		try {
+			BdfDictionary d = BdfDictionary.of(
+					new BdfEntry(GROUP_KEY_MSG_COUNT, c.getMsgCount()),
+					new BdfEntry(GROUP_KEY_UNREAD_COUNT, c.getUnreadCount()),
+					new BdfEntry(GROUP_KEY_LATEST_MSG, c.getLatestMsgTime())
+			);
+			clientHelper.mergeGroupMetadata(txn, g, d);
+		} catch (FormatException e) {
+			throw new DbException(e);
+		}
+	}
+
+	@Override
+	public void setReadFlag(GroupId g, MessageId m, boolean read)
+			throws DbException {
+		Transaction txn = db.startTransaction(false);
+		try {
+			// check current read status of message
+			BdfDictionary old =
+					clientHelper.getMessageMetadataAsDictionary(txn, m);
+			boolean wasRead = old.getBoolean(MSG_KEY_READ, false);
+
+			// if status changed
+			if (wasRead != read) {
+				// mark individual message as read
+				BdfDictionary meta = new BdfDictionary();
+				meta.put(MSG_KEY_READ, read);
+				clientHelper.mergeMessageMetadata(txn, m, meta);
+
+				// update unread counter in group metadata
+				GroupCount c = getGroupCount(txn, g);
+				BdfDictionary d = new BdfDictionary();
+				d.put(GROUP_KEY_UNREAD_COUNT,
+						c.getUnreadCount() + (read ? -1 : 1));
+				clientHelper.mergeGroupMetadata(txn, g, d);
+			}
+			txn.setComplete();
+		} catch (FormatException e) {
+			throw new DbException(e);
+		} finally {
+			db.endTransaction(txn);
+		}
+	}
+
 }
diff --git a/briar-core/src/org/briarproject/forum/ForumManagerImpl.java b/briar-core/src/org/briarproject/forum/ForumManagerImpl.java
index d4697b7598..77a7712e57 100644
--- a/briar-core/src/org/briarproject/forum/ForumManagerImpl.java
+++ b/briar-core/src/org/briarproject/forum/ForumManagerImpl.java
@@ -45,9 +45,9 @@ import static org.briarproject.api.forum.ForumConstants.KEY_LOCAL;
 import static org.briarproject.api.forum.ForumConstants.KEY_NAME;
 import static org.briarproject.api.forum.ForumConstants.KEY_PARENT;
 import static org.briarproject.api.forum.ForumConstants.KEY_PUBLIC_NAME;
-import static org.briarproject.api.forum.ForumConstants.KEY_READ;
 import static org.briarproject.api.forum.ForumConstants.KEY_TIMESTAMP;
 import static org.briarproject.api.identity.Author.Status.ANONYMOUS;
+import static org.briarproject.clients.BdfConstants.MSG_KEY_READ;
 
 class ForumManagerImpl extends BdfIncomingMessageHook implements ForumManager {
 
@@ -55,7 +55,6 @@ class ForumManagerImpl extends BdfIncomingMessageHook implements ForumManager {
 			"859a7be50dca035b64bd6902fb797097"
 					+ "795af837abbf8c16d750b3c2ccc186ea"));
 
-	private final DatabaseComponent db;
 	private final IdentityManager identityManager;
 	private final ForumFactory forumFactory;
 	private final List<RemoveForumHook> removeHooks;
@@ -64,9 +63,8 @@ class ForumManagerImpl extends BdfIncomingMessageHook implements ForumManager {
 	ForumManagerImpl(DatabaseComponent db, IdentityManager identityManager,
 			ClientHelper clientHelper, MetadataParser metadataParser,
 			ForumFactory forumFactory) {
+		super(db, clientHelper, metadataParser);
 
-		super(clientHelper, metadataParser);
-		this.db = db;
 		this.identityManager = identityManager;
 		this.forumFactory = forumFactory;
 		removeHooks = new CopyOnWriteArrayList<RemoveForumHook>();
@@ -76,6 +74,8 @@ class ForumManagerImpl extends BdfIncomingMessageHook implements ForumManager {
 	protected boolean incomingMessage(Transaction txn, Message m, BdfList body,
 			BdfDictionary meta) throws DbException, FormatException {
 
+		trackIncomingMessage(txn, m);
+
 		ForumPostHeader post = getForumPostHeader(txn, m.getId(), meta);
 		ForumPostReceivedEvent event =
 				new ForumPostReceivedEvent(post, m.getGroupId());
@@ -119,6 +119,7 @@ class ForumManagerImpl extends BdfIncomingMessageHook implements ForumManager {
 
 	@Override
 	public void addLocalPost(ForumPost p) throws DbException {
+		Transaction txn = db.startTransaction(false);
 		try {
 			BdfDictionary meta = new BdfDictionary();
 			meta.put(KEY_TIMESTAMP, p.getMessage().getTimestamp());
@@ -132,10 +133,14 @@ class ForumManagerImpl extends BdfIncomingMessageHook implements ForumManager {
 				meta.put(KEY_AUTHOR, authorMeta);
 			}
 			meta.put(KEY_LOCAL, true);
-			meta.put(KEY_READ, true);
-			clientHelper.addLocalMessage(p.getMessage(), meta, true);
+			meta.put(MSG_KEY_READ, true);
+			clientHelper.addLocalMessage(txn, p.getMessage(), meta, true);
+			trackOutgoingMessage(txn, p.getMessage());
+			txn.setComplete();
 		} catch (FormatException e) {
 			throw new RuntimeException(e);
+		} finally {
+			db.endTransaction(txn);
 		}
 	}
 
@@ -230,17 +235,6 @@ class ForumManagerImpl extends BdfIncomingMessageHook implements ForumManager {
 		}
 	}
 
-	@Override
-	public void setReadFlag(MessageId m, boolean read) throws DbException {
-		try {
-			BdfDictionary meta = new BdfDictionary();
-			meta.put(KEY_READ, read);
-			clientHelper.mergeMessageMetadata(m, meta);
-		} catch (FormatException e) {
-			throw new RuntimeException(e);
-		}
-	}
-
 	@Override
 	public void registerRemoveForumHook(RemoveForumHook hook) {
 		removeHooks.add(hook);
@@ -281,7 +275,7 @@ class ForumManagerImpl extends BdfIncomingMessageHook implements ForumManager {
 				status = identityManager.getAuthorStatus(txn, author.getId());
 			}
 		}
-		boolean read = meta.getBoolean(KEY_READ);
+		boolean read = meta.getBoolean(MSG_KEY_READ);
 
 		return new ForumPostHeader(id, parentId, timestamp, author, status,
 				read);
diff --git a/briar-core/src/org/briarproject/introduction/IntroductionManagerImpl.java b/briar-core/src/org/briarproject/introduction/IntroductionManagerImpl.java
index b07108ed84..6c44e83e6b 100644
--- a/briar-core/src/org/briarproject/introduction/IntroductionManagerImpl.java
+++ b/briar-core/src/org/briarproject/introduction/IntroductionManagerImpl.java
@@ -60,7 +60,6 @@ import static org.briarproject.api.introduction.IntroductionConstants.MESSAGE_TI
 import static org.briarproject.api.introduction.IntroductionConstants.MSG;
 import static org.briarproject.api.introduction.IntroductionConstants.NAME;
 import static org.briarproject.api.introduction.IntroductionConstants.NOT_OUR_RESPONSE;
-import static org.briarproject.api.introduction.IntroductionConstants.READ;
 import static org.briarproject.api.introduction.IntroductionConstants.REMOTE_AUTHOR_ID;
 import static org.briarproject.api.introduction.IntroductionConstants.REMOTE_AUTHOR_IS_US;
 import static org.briarproject.api.introduction.IntroductionConstants.RESPONSE_1;
@@ -75,6 +74,7 @@ import static org.briarproject.api.introduction.IntroductionConstants.TYPE_ABORT
 import static org.briarproject.api.introduction.IntroductionConstants.TYPE_ACK;
 import static org.briarproject.api.introduction.IntroductionConstants.TYPE_REQUEST;
 import static org.briarproject.api.introduction.IntroductionConstants.TYPE_RESPONSE;
+import static org.briarproject.clients.BdfConstants.MSG_KEY_READ;
 
 class IntroductionManagerImpl extends BdfIncomingMessageHook
 		implements IntroductionManager, Client, AddContactHook,
@@ -87,7 +87,6 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 	private static final Logger LOG =
 			Logger.getLogger(IntroductionManagerImpl.class.getName());
 
-	private final DatabaseComponent db;
 	private final IntroducerManager introducerManager;
 	private final IntroduceeManager introduceeManager;
 	private final IntroductionGroupFactory introductionGroupFactory;
@@ -98,8 +97,7 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 			IntroduceeManager introduceeManager,
 			IntroductionGroupFactory introductionGroupFactory) {
 
-		super(clientHelper, metadataParser);
-		this.db = db;
+		super(db, clientHelper, metadataParser);
 		this.introducerManager = introducerManager;
 		this.introduceeManager = introduceeManager;
 		this.introductionGroupFactory = introductionGroupFactory;
@@ -208,7 +206,7 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 	 */
 	@Override
 	protected boolean incomingMessage(Transaction txn, Message m, BdfList body,
-			BdfDictionary message)	throws DbException {
+			BdfDictionary message) throws DbException {
 
 		// Get message data and type
 		GroupId groupId = m.getGroupId();
@@ -237,6 +235,7 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 			}
 			try {
 				introduceeManager.incomingMessage(txn, state, message);
+				trackIncomingMessage(txn, m);
 			} catch (DbException e) {
 				if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
 				introduceeManager.abort(txn, state);
@@ -270,6 +269,7 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 						deleteMessage(txn, m.getId());
 					}
 				}
+				if (type == TYPE_RESPONSE) trackIncomingMessage(txn, m);
 			} catch (DbException e) {
 				if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
 				if (role == ROLE_INTRODUCER) introducerManager.abort(txn, state);
@@ -296,6 +296,10 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 		Transaction txn = db.startTransaction(false);
 		try {
 			introducerManager.makeIntroduction(txn, c1, c2, msg, timestamp);
+			Group g1 = introductionGroupFactory.createIntroductionGroup(c1);
+			Group g2 = introductionGroupFactory.createIntroductionGroup(c2);
+			trackMessage(txn, g1.getId(), timestamp, true);
+			trackMessage(txn, g2.getId(), timestamp, true);
 			txn.setComplete();
 		} finally {
 			db.endTransaction(txn);
@@ -315,6 +319,7 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 					getSessionState(txn, g.getId(), sessionId.getBytes());
 
 			introduceeManager.acceptIntroduction(txn, state, timestamp);
+			trackMessage(txn, g.getId(), timestamp, true);
 			txn.setComplete();
 		} finally {
 			db.endTransaction(txn);
@@ -334,6 +339,7 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 					getSessionState(txn, g.getId(), sessionId.getBytes());
 
 			introduceeManager.declineIntroduction(txn, state, timestamp);
+			trackMessage(txn, g.getId(), timestamp, true);
 			txn.setComplete();
 		} finally {
 			db.endTransaction(txn);
@@ -377,7 +383,7 @@ class IntroductionManagerImpl extends BdfIncomingMessageHook
 					boolean local;
 					long time = msg.getLong(MESSAGE_TIME);
 					boolean accepted = msg.getBoolean(ACCEPT, false);
-					boolean read = msg.getBoolean(READ, false);
+					boolean read = msg.getBoolean(MSG_KEY_READ, false);
 					AuthorId authorId;
 					String name;
 					if (type == TYPE_RESPONSE) {
diff --git a/briar-core/src/org/briarproject/messaging/MessagingManagerImpl.java b/briar-core/src/org/briarproject/messaging/MessagingManagerImpl.java
index d2af3d8a1d..d5bc6db1d3 100644
--- a/briar-core/src/org/briarproject/messaging/MessagingManagerImpl.java
+++ b/briar-core/src/org/briarproject/messaging/MessagingManagerImpl.java
@@ -33,6 +33,8 @@ import java.util.Map;
 
 import javax.inject.Inject;
 
+import static org.briarproject.clients.BdfConstants.MSG_KEY_READ;
+
 class MessagingManagerImpl extends BdfIncomingMessageHook
 		implements MessagingManager, Client, AddContactHook, RemoveContactHook {
 
@@ -40,16 +42,13 @@ class MessagingManagerImpl extends BdfIncomingMessageHook
 			"6bcdc006c0910b0f44e40644c3b31f1a"
 					+ "8bf9a6d6021d40d219c86b731b903070"));
 
-	private final DatabaseComponent db;
 	private final ContactGroupFactory contactGroupFactory;
 
 	@Inject
 	MessagingManagerImpl(DatabaseComponent db, ClientHelper clientHelper,
 			MetadataParser metadataParser,
 			ContactGroupFactory contactGroupFactory) {
-		super(clientHelper, metadataParser);
-
-		this.db = db;
+		super(db, clientHelper, metadataParser);
 		this.contactGroupFactory = contactGroupFactory;
 	}
 
@@ -100,12 +99,13 @@ class MessagingManagerImpl extends BdfIncomingMessageHook
 		long timestamp = meta.getLong("timestamp");
 		String contentType = meta.getString("contentType");
 		boolean local = meta.getBoolean("local");
-		boolean read = meta.getBoolean("read");
+		boolean read = meta.getBoolean(MSG_KEY_READ);
 		PrivateMessageHeader header = new PrivateMessageHeader(
 				m.getId(), timestamp, contentType, local, read, false, false);
 		PrivateMessageReceivedEvent event = new PrivateMessageReceivedEvent(
 				header, groupId);
 		txn.attach(event);
+		trackIncomingMessage(txn, m);
 
 		// don't share message
 		return false;
@@ -113,6 +113,7 @@ class MessagingManagerImpl extends BdfIncomingMessageHook
 
 	@Override
 	public void addLocalMessage(PrivateMessage m) throws DbException {
+		Transaction txn = db.startTransaction(false);
 		try {
 			BdfDictionary meta = new BdfDictionary();
 			meta.put("timestamp", m.getMessage().getTimestamp());
@@ -120,9 +121,13 @@ class MessagingManagerImpl extends BdfIncomingMessageHook
 			meta.put("contentType", m.getContentType());
 			meta.put("local", true);
 			meta.put("read", true);
-			clientHelper.addLocalMessage(m.getMessage(), meta, true);
+			clientHelper.addLocalMessage(txn, m.getMessage(), meta, true);
+			trackOutgoingMessage(txn, m.getMessage());
+			txn.setComplete();
 		} catch (FormatException e) {
 			throw new RuntimeException(e);
+		} finally {
+			db.endTransaction(txn);
 		}
 	}
 
@@ -196,14 +201,4 @@ class MessagingManagerImpl extends BdfIncomingMessageHook
 		}
 	}
 
-	@Override
-	public void setReadFlag(MessageId m, boolean read) throws DbException {
-		try {
-			BdfDictionary meta = new BdfDictionary();
-			meta.put("read", read);
-			clientHelper.mergeMessageMetadata(m, meta);
-		} catch (FormatException e) {
-			throw new RuntimeException(e);
-		}
-	}
 }
diff --git a/briar-core/src/org/briarproject/messaging/PrivateMessageValidator.java b/briar-core/src/org/briarproject/messaging/PrivateMessageValidator.java
index 74bd0d904e..fec178b607 100644
--- a/briar-core/src/org/briarproject/messaging/PrivateMessageValidator.java
+++ b/briar-core/src/org/briarproject/messaging/PrivateMessageValidator.java
@@ -14,6 +14,7 @@ import org.briarproject.clients.BdfMessageValidator;
 
 import static org.briarproject.api.messaging.MessagingConstants.MAX_CONTENT_TYPE_LENGTH;
 import static org.briarproject.api.messaging.MessagingConstants.MAX_PRIVATE_MESSAGE_BODY_LENGTH;
+import static org.briarproject.clients.BdfConstants.MSG_KEY_READ;
 
 class PrivateMessageValidator extends BdfMessageValidator {
 
@@ -42,7 +43,7 @@ class PrivateMessageValidator extends BdfMessageValidator {
 		if (parentId != null) meta.put("parent", parentId);
 		meta.put("contentType", contentType);
 		meta.put("local", false);
-		meta.put("read", false);
+		meta.put(MSG_KEY_READ, false);
 		return new BdfMessageContext(meta);
 	}
 }
diff --git a/briar-core/src/org/briarproject/privategroup/PrivateGroupManagerImpl.java b/briar-core/src/org/briarproject/privategroup/PrivateGroupManagerImpl.java
index 15256467ac..93623476d9 100644
--- a/briar-core/src/org/briarproject/privategroup/PrivateGroupManagerImpl.java
+++ b/briar-core/src/org/briarproject/privategroup/PrivateGroupManagerImpl.java
@@ -28,8 +28,6 @@ import java.util.Collections;
 
 import javax.inject.Inject;
 
-import static org.briarproject.privategroup.Constants.KEY_READ;
-
 public class PrivateGroupManagerImpl extends BdfIncomingMessageHook implements
 		PrivateGroupManager {
 
@@ -37,7 +35,6 @@ public class PrivateGroupManagerImpl extends BdfIncomingMessageHook implements
 			StringUtils.fromHexString("5072697661746547726f75704d616e61"
 					+ "67657220627920546f727374656e2047"));
 
-	private final DatabaseComponent db;
 	private final IdentityManager identityManager;
 	private final PrivateGroupFactory privateGroupFactory;
 
@@ -46,9 +43,8 @@ public class PrivateGroupManagerImpl extends BdfIncomingMessageHook implements
 			MetadataParser metadataParser, DatabaseComponent db,
 			IdentityManager identityManager,
 			PrivateGroupFactory privateGroupFactory) {
-		super(clientHelper, metadataParser);
+		super(db, clientHelper, metadataParser);
 
-		this.db = db;
 		this.identityManager = identityManager;
 		this.privateGroupFactory = privateGroupFactory;
 	}
@@ -61,11 +57,16 @@ public class PrivateGroupManagerImpl extends BdfIncomingMessageHook implements
 
 	@Override
 	public void addLocalMessage(GroupMessage m) throws DbException {
+		Transaction txn = db.startTransaction(false);
 		try {
 			BdfDictionary meta = new BdfDictionary();
-			clientHelper.addLocalMessage(m.getMessage(), meta, true);
+			clientHelper.addLocalMessage(txn, m.getMessage(), meta, true);
+			trackOutgoingMessage(txn, m.getMessage());
+			txn.setComplete();
 		} catch (FormatException e) {
 			throw new DbException(e);
+		} finally {
+			db.endTransaction(txn);
 		}
 	}
 
@@ -104,21 +105,12 @@ public class PrivateGroupManagerImpl extends BdfIncomingMessageHook implements
 		return Collections.emptyList();
 	}
 
-	@Override
-	public void setReadFlag(MessageId m, boolean read) throws DbException {
-		try {
-			BdfDictionary meta = new BdfDictionary();
-			meta.put(KEY_READ, read);
-			clientHelper.mergeMessageMetadata(m, meta);
-		} catch (FormatException e) {
-			throw new RuntimeException(e);
-		}
-	}
-
 	@Override
 	protected boolean incomingMessage(Transaction txn, Message m, BdfList body,
 			BdfDictionary meta) throws DbException, FormatException {
 
+		trackIncomingMessage(txn, m);
+
 		return true;
 	}
 
diff --git a/briar-core/src/org/briarproject/sharing/SharingManagerImpl.java b/briar-core/src/org/briarproject/sharing/SharingManagerImpl.java
index db29f569e4..805b9d0b4b 100644
--- a/briar-core/src/org/briarproject/sharing/SharingManagerImpl.java
+++ b/briar-core/src/org/briarproject/sharing/SharingManagerImpl.java
@@ -58,7 +58,6 @@ import static org.briarproject.api.clients.ProtocolEngine.StateUpdate;
 import static org.briarproject.api.sharing.SharingConstants.CONTACT_ID;
 import static org.briarproject.api.sharing.SharingConstants.IS_SHARER;
 import static org.briarproject.api.sharing.SharingConstants.LOCAL;
-import static org.briarproject.api.sharing.SharingConstants.READ;
 import static org.briarproject.api.sharing.SharingConstants.SESSION_ID;
 import static org.briarproject.api.sharing.SharingConstants.SHAREABLE_ID;
 import static org.briarproject.api.sharing.SharingConstants.SHARED_BY_US;
@@ -83,6 +82,7 @@ import static org.briarproject.api.sharing.SharingConstants.TO_BE_SHARED_BY_US;
 import static org.briarproject.api.sharing.SharingConstants.TYPE;
 import static org.briarproject.api.sharing.SharingMessage.BaseMessage;
 import static org.briarproject.api.sharing.SharingMessage.Invitation;
+import static org.briarproject.clients.BdfConstants.MSG_KEY_READ;
 import static org.briarproject.sharing.InviteeSessionState.State.AWAIT_LOCAL_RESPONSE;
 
 abstract class SharingManagerImpl<S extends Shareable, I extends Invitation, IS extends InviteeSessionState, SS extends SharerSessionState, IR extends InvitationReceivedEvent, IRR extends InvitationResponseReceivedEvent>
@@ -93,7 +93,6 @@ abstract class SharingManagerImpl<S extends Shareable, I extends Invitation, IS
 	private static final Logger LOG =
 			Logger.getLogger(SharingManagerImpl.class.getName());
 
-	private final DatabaseComponent db;
 	private final MessageQueueManager messageQueueManager;
 	private final MetadataEncoder metadataEncoder;
 	private final SecureRandom random;
@@ -106,9 +105,8 @@ abstract class SharingManagerImpl<S extends Shareable, I extends Invitation, IS
 			MetadataParser metadataParser, MetadataEncoder metadataEncoder,
 			SecureRandom random, ContactGroupFactory contactGroupFactory,
 			Clock clock) {
+		super(db, clientHelper, metadataParser);
 
-		super(clientHelper, metadataParser);
-		this.db = db;
 		this.messageQueueManager = messageQueueManager;
 		this.metadataEncoder = metadataEncoder;
 		this.random = random;
@@ -226,6 +224,7 @@ abstract class SharingManagerImpl<S extends Shareable, I extends Invitation, IS
 						new InviteeEngine<IS, IR>(getIRFactory());
 				processInviteeStateUpdate(txn, m.getId(),
 						engine.onMessageReceived(state, msg));
+				trackIncomingMessage(txn, m);
 			} catch (FormatException e) {
 				if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
 				deleteMessage(txn, m.getId());
@@ -239,6 +238,7 @@ abstract class SharingManagerImpl<S extends Shareable, I extends Invitation, IS
 							getIRRFactory());
 			processSharerStateUpdate(txn, m.getId(),
 					engine.onMessageReceived(state, msg));
+			trackIncomingMessage(txn, m);
 		} else if (msg.getType() == SHARE_MSG_TYPE_LEAVE ||
 				msg.getType() == SHARE_MSG_TYPE_ABORT) {
 			// we don't know who we are, so figure it out
@@ -290,6 +290,10 @@ abstract class SharingManagerImpl<S extends Shareable, I extends Invitation, IS
 					engine.onLocalAction(localState,
 							SharerSessionState.Action.LOCAL_INVITATION));
 
+			// track message
+			long time = clock.currentTimeMillis();
+			trackMessage(txn, localState.getGroupId(), time, true);
+
 			txn.setComplete();
 		} catch (FormatException e) {
 			throw new DbException();
@@ -321,6 +325,10 @@ abstract class SharingManagerImpl<S extends Shareable, I extends Invitation, IS
 			processInviteeStateUpdate(txn, null,
 					engine.onLocalAction(localState, localAction));
 
+			// track message
+			long time = clock.currentTimeMillis();
+			trackMessage(txn, localState.getGroupId(), time, true);
+
 			txn.setComplete();
 		} catch (FormatException e) {
 			throw new DbException(e);
@@ -352,7 +360,7 @@ abstract class SharingManagerImpl<S extends Shareable, I extends Invitation, IS
 							db.getMessageStatus(txn, contactId, m.getKey());
 					long time = d.getLong(TIME);
 					boolean local = d.getBoolean(LOCAL);
-					boolean read = d.getBoolean(READ, false);
+					boolean read = d.getBoolean(MSG_KEY_READ, false);
 					boolean available = false;
 
 					if (type == SHARE_MSG_TYPE_INVITATION) {
diff --git a/briar-tests/src/org/briarproject/introduction/IntroductionManagerImplTest.java b/briar-tests/src/org/briarproject/introduction/IntroductionManagerImplTest.java
index 23147e045b..e6caecad6b 100644
--- a/briar-tests/src/org/briarproject/introduction/IntroductionManagerImplTest.java
+++ b/briar-tests/src/org/briarproject/introduction/IntroductionManagerImplTest.java
@@ -4,28 +4,24 @@ import org.briarproject.BriarTestCase;
 import org.briarproject.TestUtils;
 import org.briarproject.api.FormatException;
 import org.briarproject.api.clients.ClientHelper;
-import org.briarproject.api.clients.MessageQueueManager;
-import org.briarproject.api.clients.ContactGroupFactory;
+import org.briarproject.api.clients.SessionId;
 import org.briarproject.api.contact.Contact;
 import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.data.BdfDictionary;
 import org.briarproject.api.data.BdfEntry;
 import org.briarproject.api.data.BdfList;
-import org.briarproject.api.data.MetadataEncoder;
 import org.briarproject.api.data.MetadataParser;
 import org.briarproject.api.db.DatabaseComponent;
 import org.briarproject.api.db.DbException;
 import org.briarproject.api.db.Transaction;
 import org.briarproject.api.identity.Author;
 import org.briarproject.api.identity.AuthorId;
-import org.briarproject.api.clients.SessionId;
 import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.Message;
 import org.briarproject.api.sync.MessageId;
 import org.briarproject.api.sync.MessageStatus;
-import org.briarproject.api.system.Clock;
 import org.jmock.Expectations;
 import org.jmock.Mockery;
 import org.jmock.lib.legacy.ClassImposteriser;
@@ -46,30 +42,29 @@ import static org.briarproject.api.introduction.IntroductionConstants.TYPE;
 import static org.briarproject.api.introduction.IntroductionConstants.TYPE_REQUEST;
 import static org.briarproject.api.introduction.IntroductionConstants.TYPE_RESPONSE;
 import static org.briarproject.api.sync.SyncConstants.MESSAGE_HEADER_LENGTH;
+import static org.briarproject.clients.BdfConstants.GROUP_KEY_LATEST_MSG;
+import static org.briarproject.clients.BdfConstants.GROUP_KEY_MSG_COUNT;
+import static org.briarproject.clients.BdfConstants.GROUP_KEY_UNREAD_COUNT;
 import static org.junit.Assert.assertFalse;
 
 public class IntroductionManagerImplTest extends BriarTestCase {
 
-	final Mockery context;
-	final IntroductionManagerImpl introductionManager;
-	final IntroducerManager introducerManager;
-	final IntroduceeManager introduceeManager;
-	final DatabaseComponent db;
-	final ContactGroupFactory contactGroupFactory;
-	final ClientHelper clientHelper;
-	final MetadataEncoder metadataEncoder;
-	final MessageQueueManager messageQueueManager;
-	final IntroductionGroupFactory introductionGroupFactory;
-	final Clock clock;
-	final SessionId sessionId = new SessionId(TestUtils.getRandomId());
-	final long time = 42L;
-	final Contact introducee1;
-	final Contact introducee2;
-	final Group localGroup0;
-	final Group introductionGroup1;
-	final Group introductionGroup2;
-	final Message message1;
-	Transaction txn;
+	private final Mockery context;
+	private final IntroductionManagerImpl introductionManager;
+	private final IntroducerManager introducerManager;
+	private final IntroduceeManager introduceeManager;
+	private final DatabaseComponent db;
+	private final ClientHelper clientHelper;
+	private final IntroductionGroupFactory introductionGroupFactory;
+	private final SessionId sessionId = new SessionId(TestUtils.getRandomId());
+	private final long time = 42L;
+	private final Contact introducee1;
+	private final Contact introducee2;
+	private final Group introductionGroup1;
+	private final Group introductionGroup2;
+	private final Message message1;
+	private Transaction txn;
+	private BdfDictionary metadataBefore, metadataAfter;
 
 	public IntroductionManagerImplTest() {
 		AuthorId authorId1 = new AuthorId(TestUtils.getRandomId());
@@ -89,8 +84,6 @@ public class IntroductionManagerImplTest extends BriarTestCase {
 				new Contact(contactId2, author2, localAuthorId2, true, true);
 
 		ClientId clientId = new ClientId(TestUtils.getRandomId());
-		localGroup0 = new Group(new GroupId(TestUtils.getRandomId()),
-				clientId, new byte[0]);
 		introductionGroup1 = new Group(new GroupId(TestUtils.getRandomId()),
 				clientId, new byte[0]);
 		introductionGroup2 = new Group(new GroupId(TestUtils.getRandomId()),
@@ -102,6 +95,14 @@ public class IntroductionManagerImplTest extends BriarTestCase {
 				time,
 				TestUtils.getRandomBytes(MESSAGE_HEADER_LENGTH + 1)
 		);
+		metadataBefore = BdfDictionary.of(
+				new BdfEntry(GROUP_KEY_MSG_COUNT, 41L),
+				new BdfEntry(GROUP_KEY_LATEST_MSG, 0L)
+		);
+		metadataAfter = BdfDictionary.of(
+				new BdfEntry(GROUP_KEY_MSG_COUNT, 42L),
+				new BdfEntry(GROUP_KEY_LATEST_MSG, time)
+		);
 
 		// mock ALL THE THINGS!!!
 		context = new Mockery();
@@ -109,15 +110,9 @@ public class IntroductionManagerImplTest extends BriarTestCase {
 		introducerManager = context.mock(IntroducerManager.class);
 		introduceeManager = context.mock(IntroduceeManager.class);
 		db = context.mock(DatabaseComponent.class);
-		contactGroupFactory = context.mock(ContactGroupFactory.class);
 		clientHelper = context.mock(ClientHelper.class);
-		metadataEncoder =
-				context.mock(MetadataEncoder.class);
-		messageQueueManager =
-				context.mock(MessageQueueManager.class);
 		MetadataParser metadataParser = context.mock(MetadataParser.class);
 		introductionGroupFactory = context.mock(IntroductionGroupFactory.class);
-		clock = context.mock(Clock.class);
 
 		introductionManager = new IntroductionManagerImpl(
 				db, clientHelper, metadataParser, introducerManager,
@@ -135,6 +130,27 @@ public class IntroductionManagerImplTest extends BriarTestCase {
 			oneOf(introducerManager)
 					.makeIntroduction(txn, introducee1, introducee2, null,
 							time);
+			// get both introduction groups
+			oneOf(introductionGroupFactory)
+					.createIntroductionGroup(introducee1);
+			will(returnValue(introductionGroup1));
+			oneOf(introductionGroupFactory)
+					.createIntroductionGroup(introducee2);
+			will(returnValue(introductionGroup2));
+			// track message for group 1
+			oneOf(clientHelper).getGroupMetadataAsDictionary(txn,
+					introductionGroup1.getId());
+			will(returnValue(metadataBefore));
+			oneOf(clientHelper)
+					.mergeGroupMetadata(txn, introductionGroup1.getId(),
+							metadataAfter);
+			// track message for group 2
+			oneOf(clientHelper).getGroupMetadataAsDictionary(txn,
+					introductionGroup2.getId());
+			will(returnValue(metadataBefore));
+			oneOf(clientHelper)
+					.mergeGroupMetadata(txn, introductionGroup2.getId(),
+							metadataAfter);
 			oneOf(db).endTransaction(txn);
 		}});
 
@@ -163,6 +179,13 @@ public class IntroductionManagerImplTest extends BriarTestCase {
 			oneOf(clientHelper).getMessageMetadataAsDictionary(txn, sessionId);
 			will(returnValue(state));
 			oneOf(introduceeManager).acceptIntroduction(txn, state, time);
+			// track message
+			oneOf(clientHelper).getGroupMetadataAsDictionary(txn,
+					introductionGroup1.getId());
+			will(returnValue(metadataBefore));
+			oneOf(clientHelper)
+					.mergeGroupMetadata(txn, introductionGroup1.getId(),
+							metadataAfter);
 			oneOf(db).endTransaction(txn);
 		}});
 
@@ -191,6 +214,13 @@ public class IntroductionManagerImplTest extends BriarTestCase {
 			oneOf(clientHelper).getMessageMetadataAsDictionary(txn, sessionId);
 			will(returnValue(state));
 			oneOf(introduceeManager).declineIntroduction(txn, state, time);
+			// track message
+			oneOf(clientHelper).getGroupMetadataAsDictionary(txn,
+					introductionGroup1.getId());
+			will(returnValue(metadataBefore));
+			oneOf(clientHelper)
+					.mergeGroupMetadata(txn, introductionGroup1.getId(),
+							metadataAfter);
 			oneOf(db).endTransaction(txn);
 		}});
 
@@ -241,12 +271,22 @@ public class IntroductionManagerImplTest extends BriarTestCase {
 		final BdfDictionary state = new BdfDictionary();
 		txn = new Transaction(null, false);
 
+		metadataBefore.put(GROUP_KEY_UNREAD_COUNT, 1L);
+		metadataAfter.put(GROUP_KEY_UNREAD_COUNT, 2L);
+
 		context.checking(new Expectations() {{
 			oneOf(introduceeManager)
 					.initialize(txn, introductionGroup1.getId(), msg);
 			will(returnValue(state));
 			oneOf(introduceeManager)
 					.incomingMessage(txn, state, msg);
+			// track message
+			oneOf(clientHelper).getGroupMetadataAsDictionary(txn,
+					introductionGroup1.getId());
+			will(returnValue(metadataBefore));
+			oneOf(clientHelper)
+					.mergeGroupMetadata(txn, introductionGroup1.getId(),
+							metadataAfter);
 		}});
 
 		introductionManager
@@ -272,10 +312,20 @@ public class IntroductionManagerImplTest extends BriarTestCase {
 
 		txn = new Transaction(null, false);
 
+		metadataBefore.put(GROUP_KEY_UNREAD_COUNT, 41L);
+		metadataAfter.put(GROUP_KEY_UNREAD_COUNT, 42L);
+
 		context.checking(new Expectations() {{
 			oneOf(clientHelper).getMessageMetadataAsDictionary(txn, sessionId);
 			will(returnValue(state));
 			oneOf(introducerManager).incomingMessage(txn, state, msg);
+			// track message
+			oneOf(clientHelper).getGroupMetadataAsDictionary(txn,
+					introductionGroup1.getId());
+			will(returnValue(metadataBefore));
+			oneOf(clientHelper)
+					.mergeGroupMetadata(txn, introductionGroup1.getId(),
+							metadataAfter);
 		}});
 
 		introductionManager
-- 
GitLab