diff --git a/briar-android-tests/src/test/java/org/briarproject/BriarIntegrationTest.java b/briar-android-tests/src/test/java/org/briarproject/BriarIntegrationTest.java
index 1e0486df4cd4c3986660958518619ff63162ddf2..fcd3205e73a759c6f3d652c21df7eb9a7aeb5cd5 100644
--- a/briar-android-tests/src/test/java/org/briarproject/BriarIntegrationTest.java
+++ b/briar-android-tests/src/test/java/org/briarproject/BriarIntegrationTest.java
@@ -39,6 +39,7 @@ import org.briarproject.introduction.IntroductionGroupFactory;
 import org.briarproject.introduction.IntroductionModule;
 import org.briarproject.lifecycle.LifecycleModule;
 import org.briarproject.privategroup.PrivateGroupModule;
+import org.briarproject.privategroup.invitation.GroupInvitationModule;
 import org.briarproject.properties.PropertiesModule;
 import org.briarproject.sharing.SharingModule;
 import org.briarproject.sync.SyncModule;
@@ -180,6 +181,7 @@ public abstract class BriarIntegrationTest extends BriarTestCase {
 		component.inject(new CryptoModule.EagerSingletons());
 		component.inject(new ContactModule.EagerSingletons());
 		component.inject(new ForumModule.EagerSingletons());
+		component.inject(new GroupInvitationModule.EagerSingletons());
 		component.inject(new IntroductionModule.EagerSingletons());
 		component.inject(new PropertiesModule.EagerSingletons());
 		component.inject(new PrivateGroupModule.EagerSingletons());
diff --git a/briar-android-tests/src/test/java/org/briarproject/BriarIntegrationTestComponent.java b/briar-android-tests/src/test/java/org/briarproject/BriarIntegrationTestComponent.java
index 835c345bd758afd9684e936fc421b6fb29b815b4..7d4cdf2a8a8197f63954c8d58676d9d8fd019edd 100644
--- a/briar-android-tests/src/test/java/org/briarproject/BriarIntegrationTestComponent.java
+++ b/briar-android-tests/src/test/java/org/briarproject/BriarIntegrationTestComponent.java
@@ -14,6 +14,7 @@ import org.briarproject.api.identity.IdentityManager;
 import org.briarproject.api.introduction.IntroductionManager;
 import org.briarproject.api.lifecycle.LifecycleManager;
 import org.briarproject.api.privategroup.PrivateGroupManager;
+import org.briarproject.api.privategroup.invitation.GroupInvitationManager;
 import org.briarproject.api.properties.TransportPropertyManager;
 import org.briarproject.api.sync.SyncSessionFactory;
 import org.briarproject.blogs.BlogsModule;
@@ -78,6 +79,8 @@ public interface BriarIntegrationTestComponent {
 
 	void inject(ForumModule.EagerSingletons init);
 
+	void inject(GroupInvitationModule.EagerSingletons init);
+
 	void inject(IntroductionModule.EagerSingletons init);
 
 	void inject(LifecycleModule.EagerSingletons init);
@@ -114,6 +117,8 @@ public interface BriarIntegrationTestComponent {
 
 	ForumManager getForumManager();
 
+	GroupInvitationManager getGroupInvitationManager();
+
 	IntroductionManager getIntroductionManager();
 
 	MessageTracker getMessageTracker();
diff --git a/briar-android-tests/src/test/java/org/briarproject/GroupInvitationIntegrationTest.java b/briar-android-tests/src/test/java/org/briarproject/GroupInvitationIntegrationTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..198a3039d65b155f54d23429b1bd2048a9f34253
--- /dev/null
+++ b/briar-android-tests/src/test/java/org/briarproject/GroupInvitationIntegrationTest.java
@@ -0,0 +1,415 @@
+package org.briarproject;
+
+import org.briarproject.api.clients.ProtocolStateException;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.privategroup.GroupMessage;
+import org.briarproject.api.privategroup.PrivateGroup;
+import org.briarproject.api.privategroup.PrivateGroupManager;
+import org.briarproject.api.privategroup.invitation.GroupInvitationItem;
+import org.briarproject.api.privategroup.invitation.GroupInvitationManager;
+import org.briarproject.api.privategroup.invitation.GroupInvitationRequest;
+import org.briarproject.api.privategroup.invitation.GroupInvitationResponse;
+import org.briarproject.api.sharing.InvitationMessage;
+import org.briarproject.api.sync.Group;
+import org.jetbrains.annotations.Nullable;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Collection;
+
+import static junit.framework.TestCase.fail;
+import static org.briarproject.TestUtils.assertGroupCount;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class GroupInvitationIntegrationTest extends BriarIntegrationTest {
+
+	private PrivateGroup privateGroup0;
+	private PrivateGroupManager groupManager0, groupManager1;
+	private GroupInvitationManager groupInvitationManager0,
+			groupInvitationManager1;
+
+	@Before
+	@Override
+	public void setUp() throws Exception {
+		super.setUp();
+
+		groupManager0 = c0.getPrivateGroupManager();
+		groupManager1 = c1.getPrivateGroupManager();
+		groupInvitationManager0 = c0.getGroupInvitationManager();
+		groupInvitationManager1 = c1.getGroupInvitationManager();
+
+		privateGroup0 =
+				privateGroupFactory.createPrivateGroup("Testgroup", author0);
+		long joinTime = clock.currentTimeMillis();
+		GroupMessage joinMsg0 = groupMessageFactory
+				.createJoinMessage(privateGroup0.getId(), joinTime, author0);
+		groupManager0.addPrivateGroup(privateGroup0, joinMsg0, true);
+	}
+
+	@Test
+	public void testSendInvitation() throws Exception {
+		long timestamp = clock.currentTimeMillis();
+		String msg = "Hi!";
+		sendInvitation(timestamp, msg);
+
+		sync0To1(1, true);
+
+		Collection<GroupInvitationItem> invitations =
+				groupInvitationManager1.getInvitations();
+		assertEquals(1, invitations.size());
+		GroupInvitationItem item = invitations.iterator().next();
+		assertEquals(contact0From1, item.getCreator());
+		assertEquals(privateGroup0, item.getShareable());
+		assertEquals(privateGroup0.getId(), item.getId());
+		assertEquals(privateGroup0.getName(), item.getName());
+		assertFalse(item.isSubscribed());
+
+		Collection<InvitationMessage> messages =
+				groupInvitationManager1.getInvitationMessages(contactId0From1);
+		assertEquals(1, messages.size());
+		GroupInvitationRequest request =
+				(GroupInvitationRequest) messages.iterator().next();
+		assertEquals(msg, request.getMessage());
+		assertEquals(author0, request.getCreator());
+		assertEquals(timestamp, request.getTimestamp());
+		assertEquals(contactId0From1, request.getContactId());
+		assertEquals(privateGroup0.getName(), request.getGroupName());
+		assertFalse(request.isLocal());
+		assertFalse(request.isRead());
+	}
+
+	@Test
+	public void testInvitationDecline() throws Exception {
+		long timestamp = clock.currentTimeMillis();
+		sendInvitation(timestamp, null);
+
+		sync0To1(1, true);
+		assertFalse(groupInvitationManager1.getInvitations().isEmpty());
+
+		groupInvitationManager1
+				.respondToInvitation(contactId0From1, privateGroup0, false);
+
+		Collection<InvitationMessage> messages =
+				groupInvitationManager1.getInvitationMessages(contactId0From1);
+		assertEquals(2, messages.size());
+		boolean foundResponse = false;
+		for (InvitationMessage m : messages) {
+			if (m instanceof GroupInvitationResponse) {
+				foundResponse = true;
+				GroupInvitationResponse response = (GroupInvitationResponse) m;
+				assertEquals(contactId0From1, response.getContactId());
+				assertTrue(response.isLocal());
+				assertFalse(response.wasAccepted());
+			}
+		}
+		assertTrue(foundResponse);
+
+		sync1To0(1, true);
+
+		messages =
+				groupInvitationManager0.getInvitationMessages(contactId1From0);
+		assertEquals(2, messages.size());
+		foundResponse = false;
+		for (InvitationMessage m : messages) {
+			if (m instanceof GroupInvitationResponse) {
+				foundResponse = true;
+				GroupInvitationResponse response = (GroupInvitationResponse) m;
+				assertEquals(contactId0From1, response.getContactId());
+				assertFalse(response.isLocal());
+				assertFalse(response.wasAccepted());
+			}
+		}
+		assertTrue(foundResponse);
+
+		// no invitations are open
+		assertTrue(groupInvitationManager1.getInvitations().isEmpty());
+		// no groups were added
+		assertEquals(0, groupManager1.getPrivateGroups().size());
+	}
+
+	@Test
+	public void testInvitationAccept() throws Exception {
+		long timestamp = clock.currentTimeMillis();
+		sendInvitation(timestamp, null);
+
+		sync0To1(1, true);
+		assertFalse(groupInvitationManager1.getInvitations().isEmpty());
+
+		groupInvitationManager1
+				.respondToInvitation(contactId0From1, privateGroup0, true);
+
+		Collection<InvitationMessage> messages =
+				groupInvitationManager1.getInvitationMessages(contactId0From1);
+		assertEquals(2, messages.size());
+		boolean foundResponse = false;
+		for (InvitationMessage m : messages) {
+			if (m instanceof GroupInvitationResponse) {
+				foundResponse = true;
+				GroupInvitationResponse response = (GroupInvitationResponse) m;
+				assertTrue(response.wasAccepted());
+			}
+		}
+		assertTrue(foundResponse);
+
+		sync1To0(1, true);
+
+		messages =
+				groupInvitationManager0.getInvitationMessages(contactId1From0);
+		assertEquals(2, messages.size());
+		foundResponse = false;
+		for (InvitationMessage m : messages) {
+			if (m instanceof GroupInvitationResponse) {
+				foundResponse = true;
+				GroupInvitationResponse response = (GroupInvitationResponse) m;
+				assertTrue(response.wasAccepted());
+			}
+		}
+		assertTrue(foundResponse);
+
+		// no invitations are open
+		assertTrue(groupInvitationManager1.getInvitations().isEmpty());
+		// group was added
+		Collection<PrivateGroup> groups = groupManager1.getPrivateGroups();
+		assertEquals(1, groups.size());
+		assertEquals(privateGroup0, groups.iterator().next());
+	}
+
+	@Test
+	public void testGroupCount() throws Exception {
+		long timestamp = clock.currentTimeMillis();
+		sendInvitation(timestamp, null);
+
+		// 0 has one read outgoing message
+		Group g1 = groupInvitationManager0.getContactGroup(contact1From0);
+		assertGroupCount(messageTracker0, g1.getId(), 1, 0, timestamp);
+
+		sync0To1(1, true);
+
+		// 1 has one unread message
+		Group g0 = groupInvitationManager1.getContactGroup(contact0From1);
+		assertGroupCount(messageTracker1, g0.getId(), 1, 1, timestamp);
+		InvitationMessage m =
+				groupInvitationManager1.getInvitationMessages(contactId0From1)
+						.iterator().next();
+
+		groupInvitationManager1
+				.respondToInvitation(contactId0From1, privateGroup0, true);
+
+		// 1 has two messages, one still unread
+		assertGroupCount(messageTracker1, g0.getId(), 2, 1);
+
+		// now all messages should be read
+		groupInvitationManager1.setReadFlag(g0.getId(), m.getId(), true);
+		assertGroupCount(messageTracker1, g0.getId(), 2, 0);
+
+		sync1To0(1, true);
+
+		// now 0 has two messages, one of them unread
+		assertGroupCount(messageTracker0, g1.getId(), 2, 1);
+	}
+
+	@Test
+	public void testMultipleInvitations() throws Exception {
+		sendInvitation(clock.currentTimeMillis(), null);
+
+		// invitation is not allowed before the first hasn't been answered
+		assertFalse(groupInvitationManager0
+				.isInvitationAllowed(contact1From0, privateGroup0.getId()));
+
+		// deliver invitation and response
+		sync0To1(1, true);
+		groupInvitationManager1
+				.respondToInvitation(contactId0From1, privateGroup0, false);
+		sync1To0(1, true);
+
+		// after invitation was declined, inviting again is possible
+		assertTrue(groupInvitationManager0
+				.isInvitationAllowed(contact1From0, privateGroup0.getId()));
+
+		// send and accept the second invitation
+		sendInvitation(clock.currentTimeMillis(), "Second Invitation");
+		sync0To1(1, true);
+		groupInvitationManager1
+				.respondToInvitation(contactId0From1, privateGroup0, true);
+		sync1To0(1, true);
+
+		// invitation is not allowed since the member joined the group now
+		assertFalse(groupInvitationManager0
+				.isInvitationAllowed(contact1From0, privateGroup0.getId()));
+
+		// don't allow another invitation request
+		try {
+			sendInvitation(clock.currentTimeMillis(), "Third Invitation");
+			fail();
+		} catch (ProtocolStateException e) {
+			// expected
+		}
+	}
+
+	@Test(expected = ProtocolStateException.class)
+	public void testInvitationsWithSameTimestamp() throws Exception {
+		long timestamp = clock.currentTimeMillis();
+		sendInvitation(timestamp, null);
+		sync0To1(1, true);
+
+		groupInvitationManager1
+				.respondToInvitation(contactId0From1, privateGroup0, false);
+		sync1To0(1, true);
+
+		sendInvitation(timestamp, "Second Invitation");
+		sync0To1(1, true);
+
+		groupInvitationManager1
+				.respondToInvitation(contactId0From1, privateGroup0, true);
+	}
+
+	@Test(expected = ProtocolStateException.class)
+	public void testCreatorLeavesBeforeInvitationAccepted() throws Exception {
+		// Creator invites invitee to join group
+		sendInvitation(clock.currentTimeMillis(), null);
+
+		// Creator's invite message is delivered to invitee
+		sync0To1(1, true);
+
+		// Creator leaves group
+		assertEquals(1, groupManager0.getPrivateGroups().size());
+		groupManager0.removePrivateGroup(privateGroup0.getId());
+		assertEquals(0, groupManager0.getPrivateGroups().size());
+
+		// Creator's leave message is delivered to invitee
+		sync0To1(1, true);
+
+		// Invitee accepts invitation, but it's no longer open - exception is
+		// thrown as the action has failed
+		assertEquals(0, groupManager1.getPrivateGroups().size());
+		groupInvitationManager1
+				.respondToInvitation(contactId0From1, privateGroup0, true);
+	}
+
+	@Test
+	public void testCreatorLeavesBeforeInvitationDeclined() throws Exception {
+		// Creator invites invitee to join group
+		sendInvitation(clock.currentTimeMillis(), null);
+
+		// Creator's invite message is delivered to invitee
+		sync0To1(1, true);
+
+		// Creator leaves group
+		assertEquals(1, groupManager0.getPrivateGroups().size());
+		groupManager0.removePrivateGroup(privateGroup0.getId());
+		assertEquals(0, groupManager0.getPrivateGroups().size());
+
+		// Creator's leave message is delivered to invitee
+		sync0To1(1, true);
+
+		// Invitee declines invitation, but it's no longer open - no exception
+		// as the action has succeeded
+		assertEquals(0, groupManager1.getPrivateGroups().size());
+		groupInvitationManager1
+				.respondToInvitation(contactId0From1, privateGroup0, false);
+	}
+
+	@Test
+	public void testCreatorLeavesConcurrentlyWithInvitationAccepted()
+			throws Exception {
+		// Creator invites invitee to join group
+		sendInvitation(clock.currentTimeMillis(), null);
+
+		// Creator's invite message is delivered to invitee
+		sync0To1(1, true);
+
+		// Creator leaves group
+		assertEquals(1, groupManager0.getPrivateGroups().size());
+		groupManager0.removePrivateGroup(privateGroup0.getId());
+		assertEquals(0, groupManager0.getPrivateGroups().size());
+
+		// Invitee accepts invitation
+		assertEquals(0, groupManager1.getPrivateGroups().size());
+		groupInvitationManager1
+				.respondToInvitation(contactId0From1, privateGroup0, true);
+		assertEquals(1, groupManager1.getPrivateGroups().size());
+		assertFalse(groupManager1.isDissolved(privateGroup0.getId()));
+
+		// Invitee's join message is delivered to creator
+		sync1To0(1, true);
+
+		// Creator's leave message is delivered to invitee
+		sync0To1(1, true);
+
+		// Group is marked as dissolved
+		assertTrue(groupManager1.isDissolved(privateGroup0.getId()));
+	}
+
+	@Test
+	public void testCreatorLeavesConcurrentlyWithInvitationDeclined()
+			throws Exception {
+		// Creator invites invitee to join group
+		sendInvitation(clock.currentTimeMillis(), null);
+
+		// Creator's invite message is delivered to invitee
+		sync0To1(1, true);
+
+		// Creator leaves group
+		assertEquals(1, groupManager0.getPrivateGroups().size());
+		groupManager0.removePrivateGroup(privateGroup0.getId());
+		assertEquals(0, groupManager0.getPrivateGroups().size());
+
+		// Invitee declines invitation
+		assertEquals(0, groupManager1.getPrivateGroups().size());
+		groupInvitationManager1
+				.respondToInvitation(contactId0From1, privateGroup0, false);
+		assertEquals(0, groupManager1.getPrivateGroups().size());
+
+		// Invitee's leave message is delivered to creator
+		sync1To0(1, true);
+
+		// Creator's leave message is delivered to invitee
+		sync0To1(1, true);
+	}
+
+	@Test
+	public void testCreatorLeavesConcurrentlyWithMemberLeaving()
+			throws Exception {
+		// Creator invites invitee to join group
+		sendInvitation(clock.currentTimeMillis(), null);
+
+		// Creator's invite message is delivered to invitee
+		sync0To1(1, true);
+
+		// Invitee responds to invitation
+		assertEquals(0, groupManager1.getPrivateGroups().size());
+		groupInvitationManager1
+				.respondToInvitation(contactId0From1, privateGroup0, true);
+		assertEquals(1, groupManager1.getPrivateGroups().size());
+
+		// Invitee's join message is delivered to creator
+		sync1To0(1, true);
+
+		// Creator leaves group
+		assertEquals(1, groupManager0.getPrivateGroups().size());
+		groupManager0.removePrivateGroup(privateGroup0.getId());
+		assertEquals(0, groupManager0.getPrivateGroups().size());
+
+		// Invitee leaves group
+		groupManager1.removePrivateGroup(privateGroup0.getId());
+		assertEquals(0, groupManager1.getPrivateGroups().size());
+
+		// Creator's leave message is delivered to invitee
+		sync0To1(1, true);
+
+		// Invitee's leave message is delivered to creator
+		sync1To0(1, true);
+	}
+
+	private void sendInvitation(long timestamp, @Nullable String msg) throws
+			DbException {
+		byte[] signature = groupInvitationFactory.signInvitation(contact1From0,
+				privateGroup0.getId(), timestamp, author0.getPrivateKey());
+		groupInvitationManager0
+				.sendInvitation(privateGroup0.getId(), contactId1From0, msg,
+						timestamp, signature);
+	}
+
+}
diff --git a/briar-android-tests/src/test/java/org/briarproject/PrivateGroupIntegrationTest.java b/briar-android-tests/src/test/java/org/briarproject/PrivateGroupIntegrationTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..f1b96df29fb5011dc98d50b08948a0a5d8c7036a
--- /dev/null
+++ b/briar-android-tests/src/test/java/org/briarproject/PrivateGroupIntegrationTest.java
@@ -0,0 +1,205 @@
+package org.briarproject;
+
+import org.briarproject.api.contact.Contact;
+import org.briarproject.api.contact.ContactId;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.identity.AuthorId;
+import org.briarproject.api.privategroup.GroupMember;
+import org.briarproject.api.privategroup.GroupMessage;
+import org.briarproject.api.privategroup.GroupMessageHeader;
+import org.briarproject.api.privategroup.JoinMessageHeader;
+import org.briarproject.api.privategroup.PrivateGroup;
+import org.briarproject.api.privategroup.PrivateGroupManager;
+import org.briarproject.api.privategroup.invitation.GroupInvitationManager;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.MessageId;
+import org.jetbrains.annotations.Nullable;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Collection;
+
+import static org.briarproject.api.identity.Author.Status.OURSELVES;
+import static org.briarproject.api.privategroup.Visibility.INVISIBLE;
+import static org.briarproject.api.privategroup.Visibility.REVEALED_BY_CONTACT;
+import static org.briarproject.api.privategroup.Visibility.REVEALED_BY_US;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * This class tests how PrivateGroupManager and GroupInvitationManager
+ * play together.
+ */
+public class PrivateGroupIntegrationTest extends BriarIntegrationTest {
+
+	private GroupId groupId0;
+	private PrivateGroup privateGroup0;
+	private PrivateGroupManager groupManager0, groupManager1, groupManager2;
+	private GroupInvitationManager groupInvitationManager0,
+			groupInvitationManager1, groupInvitationManager2;
+
+	@Before
+	@Override
+	public void setUp() throws Exception {
+		super.setUp();
+
+		groupManager0 = c0.getPrivateGroupManager();
+		groupManager1 = c1.getPrivateGroupManager();
+		groupManager2 = c2.getPrivateGroupManager();
+		groupInvitationManager0 = c0.getGroupInvitationManager();
+		groupInvitationManager1 = c1.getGroupInvitationManager();
+		groupInvitationManager2 = c2.getGroupInvitationManager();
+
+		privateGroup0 =
+				privateGroupFactory.createPrivateGroup("Test Group", author0);
+		groupId0 = privateGroup0.getId();
+		long joinTime = clock.currentTimeMillis();
+		GroupMessage joinMsg0 = groupMessageFactory
+				.createJoinMessage(groupId0, joinTime, author0);
+		groupManager0.addPrivateGroup(privateGroup0, joinMsg0, true);
+	}
+
+	@Test
+	public void testMembership() throws Exception {
+		sendInvitation(contactId1From0, clock.currentTimeMillis(), "Hi!");
+
+		// our group has only one member (ourselves)
+		Collection<GroupMember> members = groupManager0.getMembers(groupId0);
+		assertEquals(1, members.size());
+		assertEquals(author0, members.iterator().next().getAuthor());
+		assertEquals(OURSELVES, members.iterator().next().getStatus());
+
+		sync0To1(1, true);
+		groupInvitationManager1
+				.respondToInvitation(contactId0From1, privateGroup0, true);
+		sync1To0(1, true);
+
+		// sync group join messages
+		sync0To1(2, true); // + one invitation protocol join message
+		sync1To0(1, true);
+
+		// now the group has two members
+		members = groupManager0.getMembers(groupId0);
+		assertEquals(2, members.size());
+		for (GroupMember m : members) {
+			if (m.getStatus() == OURSELVES) {
+				assertEquals(author0.getId(), m.getAuthor().getId());
+			} else {
+				assertEquals(author1.getId(), m.getAuthor().getId());
+			}
+		}
+
+		members = groupManager1.getMembers(groupId0);
+		assertEquals(2, members.size());
+		for (GroupMember m : members) {
+			if (m.getStatus() == OURSELVES) {
+				assertEquals(author1.getId(), m.getAuthor().getId());
+			} else {
+				assertEquals(author0.getId(), m.getAuthor().getId());
+			}
+		}
+	}
+
+	@Test
+	public void testRevealContacts() throws Exception {
+		// invite two contacts
+		sendInvitation(contactId1From0, clock.currentTimeMillis(), "Hi 1!");
+		sendInvitation(contactId2From0, clock.currentTimeMillis(), "Hi 2!");
+		sync0To1(1, true);
+		sync0To2(1, true);
+
+		// accept both invitations
+		groupInvitationManager1
+				.respondToInvitation(contactId0From1, privateGroup0, true);
+		groupInvitationManager2
+				.respondToInvitation(contactId0From2, privateGroup0, true);
+		sync1To0(1, true);
+		sync2To0(1, true);
+
+		// sync group join messages
+		sync0To1(2, true); // + one invitation protocol join message
+		assertEquals(2, groupManager1.getMembers(groupId0).size());
+		sync1To0(1, true);
+		assertEquals(2, groupManager0.getMembers(groupId0).size());
+		sync0To2(3, true); // 2 join messages and 1 invite join message
+		assertEquals(3, groupManager2.getMembers(groupId0).size());
+		sync2To0(1, true);
+		assertEquals(3, groupManager0.getMembers(groupId0).size());
+		sync0To1(1, true);
+		assertEquals(3, groupManager1.getMembers(groupId0).size());
+
+		// 1 and 2 add each other as contacts
+		addContacts1And2();
+
+		// their relationship is still invisible
+		assertEquals(INVISIBLE,
+				getGroupMember(groupManager1, author2.getId()).getVisibility());
+		assertEquals(INVISIBLE,
+				getGroupMember(groupManager2, author1.getId()).getVisibility());
+
+		// 1 reveals the contact relationship to 2
+		assertTrue(contactId2From1 != null);
+		groupInvitationManager1.revealRelationship(contactId2From1, groupId0);
+		sync1To2(1, true);
+		sync2To1(1, true);
+
+		// their relationship is now revealed
+		assertEquals(REVEALED_BY_US,
+				getGroupMember(groupManager1, author2.getId()).getVisibility());
+		assertEquals(REVEALED_BY_CONTACT,
+				getGroupMember(groupManager2, author1.getId()).getVisibility());
+
+		// 2 sends a message to the group
+		long time = clock.currentTimeMillis();
+		String body = "This is a test message!";
+		MessageId previousMsgId = groupManager2.getPreviousMsgId(groupId0);
+		GroupMessage msg = groupMessageFactory
+				.createGroupMessage(groupId0, time, null, author2, body,
+						previousMsgId);
+		groupManager2.addLocalMessage(msg);
+
+		// 1 has only the three join messages in the group
+		Collection<GroupMessageHeader> headers =
+				groupManager1.getHeaders(groupId0);
+		assertEquals(3, headers.size());
+
+		// message should sync to 1 without creator (0) being involved
+		sync2To1(1, true);
+		headers = groupManager1.getHeaders(groupId0);
+		assertEquals(4, headers.size());
+		boolean foundPost = false;
+		for (GroupMessageHeader h : headers) {
+			if (h instanceof JoinMessageHeader) continue;
+			foundPost = true;
+			assertEquals(time, h.getTimestamp());
+			assertEquals(groupId0, h.getGroupId());
+			assertEquals(author2.getId(), h.getAuthor().getId());
+		}
+		assertTrue(foundPost);
+
+		// message should sync from 1 to 0 without 2 being involved
+		sync1To0(1, true);
+		headers = groupManager0.getHeaders(groupId0);
+		assertEquals(4, headers.size());
+	}
+
+	private void sendInvitation(ContactId c, long timestamp,
+			@Nullable String msg) throws DbException {
+		Contact contact = contactManager0.getContact(c);
+		byte[] signature = groupInvitationFactory
+				.signInvitation(contact, groupId0, timestamp,
+						author0.getPrivateKey());
+		groupInvitationManager0
+				.sendInvitation(groupId0, c, msg, timestamp, signature);
+	}
+
+	private GroupMember getGroupMember(PrivateGroupManager groupManager,
+			AuthorId a) throws DbException {
+		Collection<GroupMember> members = groupManager.getMembers(groupId0);
+		for (GroupMember m : members) {
+			if (m.getAuthor().getId().equals(a)) return m;
+		}
+		throw new AssertionError();
+	}
+
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationManagerImpl.java b/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationManagerImpl.java
index 5be3b4573143e41cb97234ffbebcba73e9ca8723..6873a910dbe705aba397ab1fab3444bbd0f954b4 100644
--- a/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationManagerImpl.java
+++ b/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationManagerImpl.java
@@ -74,7 +74,7 @@ class GroupInvitationManagerImpl extends ConversationClientImpl
 	private final Group localGroup;
 
 	@Inject
-	protected GroupInvitationManagerImpl(DatabaseComponent db,
+	GroupInvitationManagerImpl(DatabaseComponent db,
 			ClientHelper clientHelper, MetadataParser metadataParser,
 			MessageTracker messageTracker,
 			ContactGroupFactory contactGroupFactory,
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/InviteeProtocolEngine.java b/briar-core/src/org/briarproject/privategroup/invitation/InviteeProtocolEngine.java
index fe03fdb7c6ac5207fa9916905b7c28137b798b63..231af013511dc8b8dd2df537266948ae4e8094ea 100644
--- a/briar-core/src/org/briarproject/privategroup/invitation/InviteeProtocolEngine.java
+++ b/briar-core/src/org/briarproject/privategroup/invitation/InviteeProtocolEngine.java
@@ -148,10 +148,11 @@ class InviteeProtocolEngine extends AbstractProtocolEngine<InviteeSession> {
 			case DISSOLVED:
 				return abort(txn, s); // Invalid in these states
 			case INVITED:
+			case LEFT:
+				return onRemoteLeaveWhenNotSubscribed(txn, s, m);
 			case ACCEPTED:
 			case JOINED:
-			case LEFT:
-				return onRemoteLeave(txn, s, m);
+				return onRemoteLeaveWhenSubscribed(txn, s, m);
 			case ERROR:
 				return s; // Ignored in this state
 			default:
@@ -260,8 +261,23 @@ class InviteeProtocolEngine extends AbstractProtocolEngine<InviteeSession> {
 				s.getInviteTimestamp(), JOINED);
 	}
 
-	private InviteeSession onRemoteLeave(Transaction txn, InviteeSession s,
-			LeaveMessage m) throws DbException, FormatException {
+	private InviteeSession onRemoteLeaveWhenNotSubscribed(Transaction txn,
+			InviteeSession s, LeaveMessage m)
+			throws DbException, FormatException {
+		// The timestamp must be higher than the last invite message, if any
+		if (m.getTimestamp() <= s.getInviteTimestamp()) return abort(txn, s);
+		// The dependency, if any, must be the last remote message
+		if (!isValidDependency(s, m.getPreviousMessageId()))
+			return abort(txn, s);
+		// Move to the DISSOLVED state
+		return new InviteeSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				s.getLastLocalMessageId(), m.getId(), s.getLocalTimestamp(),
+				s.getInviteTimestamp(), DISSOLVED);
+	}
+
+	private InviteeSession onRemoteLeaveWhenSubscribed(Transaction txn,
+			InviteeSession s, LeaveMessage m)
+			throws DbException, FormatException {
 		// The timestamp must be higher than the last invite message, if any
 		if (m.getTimestamp() <= s.getInviteTimestamp()) return abort(txn, s);
 		// The dependency, if any, must be the last remote message
diff --git a/briar-tests/src/org/briarproject/forum/ForumPostValidatorTest.java b/briar-tests/src/org/briarproject/forum/ForumPostValidatorTest.java
index af048f6f1a7e68d14d7fb9d07f7fb489bd4179fd..52fcd5d721ff0a81d4b34e9a5fb762cff4617fb4 100644
--- a/briar-tests/src/org/briarproject/forum/ForumPostValidatorTest.java
+++ b/briar-tests/src/org/briarproject/forum/ForumPostValidatorTest.java
@@ -8,7 +8,6 @@ import org.briarproject.api.clients.BdfMessageContext;
 import org.briarproject.api.data.BdfDictionary;
 import org.briarproject.api.data.BdfList;
 import org.briarproject.api.identity.Author;
-import org.briarproject.api.identity.AuthorFactory;
 import org.briarproject.api.identity.AuthorId;
 import org.briarproject.api.sync.InvalidMessageException;
 import org.briarproject.api.sync.MessageId;
@@ -29,9 +28,6 @@ import static org.junit.Assert.assertFalse;
 
 public class ForumPostValidatorTest extends ValidatorTestCase {
 
-	private final AuthorFactory authorFactory =
-			context.mock(AuthorFactory.class);
-
 	private final MessageId parentId = new MessageId(TestUtils.getRandomId());
 	private final String authorName =
 			TestUtils.getRandomString(MAX_AUTHOR_NAME_LENGTH);