From a38f39207f6a6d8d6454450214f56a2289f9fca4 Mon Sep 17 00:00:00 2001
From: akwizgran <michael@briarproject.org>
Date: Thu, 12 Apr 2018 14:23:03 +0100
Subject: [PATCH] Initial implementation of client versioning client.

---
 .../api/sync/ClientVersioningManager.java     |  32 +
 .../bramble/api/sync/SyncConstants.java       |   4 +-
 .../sync/ClientVersioningConstants.java       |  10 +
 .../sync/ClientVersioningManagerImpl.java     | 566 ++++++++++++++++++
 .../sync/ClientVersioningValidator.java       |  59 ++
 .../briarproject/bramble/sync/SyncModule.java |  35 ++
 .../briar/test/BriarIntegrationTest.java      |  19 +-
 7 files changed, 719 insertions(+), 6 deletions(-)
 create mode 100644 bramble-api/src/main/java/org/briarproject/bramble/api/sync/ClientVersioningManager.java
 create mode 100644 bramble-core/src/main/java/org/briarproject/bramble/sync/ClientVersioningConstants.java
 create mode 100644 bramble-core/src/main/java/org/briarproject/bramble/sync/ClientVersioningManagerImpl.java
 create mode 100644 bramble-core/src/main/java/org/briarproject/bramble/sync/ClientVersioningValidator.java

diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/sync/ClientVersioningManager.java b/bramble-api/src/main/java/org/briarproject/bramble/api/sync/ClientVersioningManager.java
new file mode 100644
index 0000000000..a449233731
--- /dev/null
+++ b/bramble-api/src/main/java/org/briarproject/bramble/api/sync/ClientVersioningManager.java
@@ -0,0 +1,32 @@
+package org.briarproject.bramble.api.sync;
+
+import org.briarproject.bramble.api.contact.Contact;
+import org.briarproject.bramble.api.db.DbException;
+import org.briarproject.bramble.api.db.Transaction;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.sync.Group.Visibility;
+
+@NotNullByDefault
+public interface ClientVersioningManager {
+
+	/**
+	 * The unique ID of the versioning client.
+	 */
+	ClientId CLIENT_ID = new ClientId("org.briarproject.bramble.versioning");
+
+	/**
+	 * The current version of the versioning client.
+	 */
+	int CLIENT_VERSION = 0;
+
+	void registerClient(ClientId clientId, int clientVersion);
+
+	void registerClientVersioningHook(ClientId clientId, int clientVersion,
+			ClientVersioningHook hook);
+
+	interface ClientVersioningHook {
+
+		void onClientVisibilityChanging(Transaction txn, Contact c,
+				Visibility v) throws DbException;
+	}
+}
diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/sync/SyncConstants.java b/bramble-api/src/main/java/org/briarproject/bramble/api/sync/SyncConstants.java
index 80f48da666..5f196f1470 100644
--- a/bramble-api/src/main/java/org/briarproject/bramble/api/sync/SyncConstants.java
+++ b/bramble-api/src/main/java/org/briarproject/bramble/api/sync/SyncConstants.java
@@ -19,7 +19,9 @@ public interface SyncConstants {
 	 */
 	int MAX_RECORD_PAYLOAD_LENGTH = 48 * 1024; // 48 KiB
 
-	/** The maximum length of a group descriptor in bytes. */
+	/**
+	 * The maximum length of a group descriptor in bytes.
+	 */
 	int MAX_GROUP_DESCRIPTOR_LENGTH = 16 * 1024; // 16 KiB
 
 	/**
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/sync/ClientVersioningConstants.java b/bramble-core/src/main/java/org/briarproject/bramble/sync/ClientVersioningConstants.java
new file mode 100644
index 0000000000..4578fa88d8
--- /dev/null
+++ b/bramble-core/src/main/java/org/briarproject/bramble/sync/ClientVersioningConstants.java
@@ -0,0 +1,10 @@
+package org.briarproject.bramble.sync;
+
+interface ClientVersioningConstants {
+
+	// Metadata keys
+	String MSG_KEY_UPDATE_VERSION = "version";
+	String MSG_KEY_LOCAL = "local";
+	String GROUP_KEY_CONTACT_ID = "contactId";
+}
+
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/sync/ClientVersioningManagerImpl.java b/bramble-core/src/main/java/org/briarproject/bramble/sync/ClientVersioningManagerImpl.java
new file mode 100644
index 0000000000..9cb4d9c63f
--- /dev/null
+++ b/bramble-core/src/main/java/org/briarproject/bramble/sync/ClientVersioningManagerImpl.java
@@ -0,0 +1,566 @@
+package org.briarproject.bramble.sync;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.client.ContactGroupFactory;
+import org.briarproject.bramble.api.contact.Contact;
+import org.briarproject.bramble.api.contact.ContactId;
+import org.briarproject.bramble.api.contact.ContactManager.ContactHook;
+import org.briarproject.bramble.api.data.BdfDictionary;
+import org.briarproject.bramble.api.data.BdfList;
+import org.briarproject.bramble.api.db.DatabaseComponent;
+import org.briarproject.bramble.api.db.DbException;
+import org.briarproject.bramble.api.db.Metadata;
+import org.briarproject.bramble.api.db.Transaction;
+import org.briarproject.bramble.api.lifecycle.Service;
+import org.briarproject.bramble.api.lifecycle.ServiceException;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.sync.Client;
+import org.briarproject.bramble.api.sync.ClientId;
+import org.briarproject.bramble.api.sync.ClientVersioningManager;
+import org.briarproject.bramble.api.sync.Group;
+import org.briarproject.bramble.api.sync.Group.Visibility;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.InvalidMessageException;
+import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.bramble.api.sync.ValidationManager.IncomingMessageHook;
+import org.briarproject.bramble.api.system.Clock;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+
+import static java.util.Collections.emptyList;
+import static org.briarproject.bramble.api.sync.Group.Visibility.INVISIBLE;
+import static org.briarproject.bramble.api.sync.Group.Visibility.SHARED;
+import static org.briarproject.bramble.api.sync.Group.Visibility.VISIBLE;
+import static org.briarproject.bramble.sync.ClientVersioningConstants.GROUP_KEY_CONTACT_ID;
+import static org.briarproject.bramble.sync.ClientVersioningConstants.MSG_KEY_LOCAL;
+import static org.briarproject.bramble.sync.ClientVersioningConstants.MSG_KEY_UPDATE_VERSION;
+
+@NotNullByDefault
+class ClientVersioningManagerImpl implements ClientVersioningManager, Client,
+		Service, ContactHook, IncomingMessageHook {
+
+	private final DatabaseComponent db;
+	private final ClientHelper clientHelper;
+	private final ContactGroupFactory contactGroupFactory;
+	private final Clock clock;
+	private final Group localGroup;
+
+	private final Collection<ClientVersion> clients =
+			new CopyOnWriteArrayList<>();
+	private final Map<ClientVersion, ClientVersioningHook> hooks =
+			new ConcurrentHashMap<>();
+
+	@Inject
+	ClientVersioningManagerImpl(DatabaseComponent db,
+			ClientHelper clientHelper, ContactGroupFactory contactGroupFactory,
+			Clock clock) {
+		this.db = db;
+		this.clientHelper = clientHelper;
+		this.contactGroupFactory = contactGroupFactory;
+		this.clock = clock;
+		localGroup = contactGroupFactory.createLocalGroup(CLIENT_ID,
+				CLIENT_VERSION);
+	}
+
+	@Override
+	public void registerClient(ClientId clientId, int clientVersion) {
+		clients.add(new ClientVersion(clientId, clientVersion));
+	}
+
+	@Override
+	public void registerClientVersioningHook(ClientId clientId,
+			int clientVersion, ClientVersioningHook hook) {
+		hooks.put(new ClientVersion(clientId, clientVersion), hook);
+	}
+
+	@Override
+	public void createLocalState(Transaction txn) throws DbException {
+		if (db.containsGroup(txn, localGroup.getId())) return;
+		db.addGroup(txn, localGroup);
+		// Set things up for any pre-existing contacts
+		for (Contact c : db.getContacts(txn)) addingContact(txn, c);
+	}
+
+	@Override
+	public void startService() throws ServiceException {
+		List<ClientVersion> versions = new ArrayList<>(clients);
+		Collections.sort(versions);
+		try {
+			Transaction txn = db.startTransaction(false);
+			try {
+				if (updateClientVersions(txn, versions)) {
+					for (Contact c : db.getContacts(txn))
+						clientVersionsUpdated(txn, c, versions);
+				}
+				db.commitTransaction(txn);
+			} finally {
+				db.endTransaction(txn);
+			}
+		} catch (DbException e) {
+			throw new ServiceException(e);
+		}
+	}
+
+	@Override
+	public void stopService() throws ServiceException {
+	}
+
+	@Override
+	public void addingContact(Transaction txn, Contact c) throws DbException {
+		// Create a group and share it with the contact
+		Group g = getContactGroup(c);
+		db.addGroup(txn, g);
+		db.setGroupVisibility(txn, c.getId(), g.getId(), SHARED);
+		// Attach the contact ID to the group
+		BdfDictionary meta = new BdfDictionary();
+		meta.put(GROUP_KEY_CONTACT_ID, c.getId().getInt());
+		try {
+			clientHelper.mergeGroupMetadata(txn, g.getId(), meta);
+		} catch (FormatException e) {
+			throw new AssertionError(e);
+		}
+		// Create and store the first local update
+		List<ClientVersion> versions = new ArrayList<>(clients);
+		Collections.sort(versions);
+		storeFirstUpdate(txn, g.getId(), versions);
+	}
+
+	@Override
+	public void removingContact(Transaction txn, Contact c) throws DbException {
+		db.removeGroup(txn, getContactGroup(c));
+	}
+
+	@Override
+	public boolean incomingMessage(Transaction txn, Message m, Metadata meta)
+			throws DbException, InvalidMessageException {
+		try {
+			// Parse the new remote update
+			Update newRemoteUpdate = parseUpdate(clientHelper.toList(m));
+			List<ClientState> newRemoteStates = newRemoteUpdate.states;
+			long newRemoteUpdateVersion = newRemoteUpdate.updateVersion;
+			// Find the latest local and remote updates, if any
+			LatestUpdates latest = findLatestUpdates(txn, m.getGroupId());
+			// If this update is obsolete, delete it and return
+			if (latest.remote != null
+					&& latest.remote.updateVersion > newRemoteUpdateVersion) {
+				db.deleteMessage(txn, m.getId());
+				db.deleteMessageMetadata(txn, m.getId());
+				return false;
+			}
+			// Load and parse the latest local update
+			if (latest.local == null) throw new DbException();
+			Update oldLocalUpdate = loadUpdate(txn, latest.local.messageId);
+			List<ClientState> oldLocalStates = oldLocalUpdate.states;
+			long oldLocalUpdateVersion = oldLocalUpdate.updateVersion;
+			// Load and parse the previous remote update, if any
+			List<ClientState> oldRemoteStates;
+			if (latest.remote == null) {
+				oldRemoteStates = emptyList();
+			} else {
+				oldRemoteStates =
+						loadUpdate(txn, latest.remote.messageId).states;
+				// Delete the previous remote update
+				db.deleteMessage(txn, latest.remote.messageId);
+				db.deleteMessageMetadata(txn, latest.remote.messageId);
+			}
+			// Update the local states from the remote states if necessary
+			List<ClientState> newLocalStates = updateStatesFromRemoteStates(
+					oldLocalStates, newRemoteStates);
+			if (!oldLocalStates.equals(newLocalStates)) {
+				// Delete the latest local update
+				db.deleteMessage(txn, latest.local.messageId);
+				db.deleteMessageMetadata(txn, latest.local.messageId);
+				// Store a new local update
+				storeUpdate(txn, m.getGroupId(), newLocalStates,
+						oldLocalUpdateVersion + 1);
+			}
+			// Calculate the old and new client visibilities
+			Map<ClientVersion, Visibility> before =
+					getVisibilities(oldLocalStates, oldRemoteStates);
+			Map<ClientVersion, Visibility> after =
+					getVisibilities(newLocalStates, newRemoteStates);
+			// Call hooks for any visibilities that have changed
+			Contact c = getContact(txn, m.getGroupId());
+			callVisibilityHooks(txn, c, before, after);
+		} catch (FormatException e) {
+			throw new InvalidMessageException(e);
+		}
+		return false;
+	}
+
+	private void storeClientVersions(Transaction txn,
+			List<ClientVersion> versions) throws DbException {
+		long now = clock.currentTimeMillis();
+		BdfList body = encodeClientVersions(versions);
+		try {
+			Message m = clientHelper.createMessage(localGroup.getId(), now,
+					body);
+			db.addLocalMessage(txn, m, new Metadata(), false);
+		} catch (FormatException e) {
+			throw new AssertionError(e);
+		}
+	}
+
+	private BdfList encodeClientVersions(List<ClientVersion> versions) {
+		BdfList encoded = new BdfList();
+		for (ClientVersion cv : versions)
+			encoded.add(BdfList.of(cv.clientId.getString(), cv.clientVersion));
+		return encoded;
+	}
+
+	private boolean updateClientVersions(Transaction txn,
+			List<ClientVersion> newVersions) throws DbException {
+		Collection<MessageId> ids = db.getMessageIds(txn, localGroup.getId());
+		if (ids.isEmpty()) {
+			storeClientVersions(txn, newVersions);
+			return true;
+		}
+		MessageId m = ids.iterator().next();
+		List<ClientVersion> oldVersions = loadClientVersions(txn, m);
+		if (oldVersions.equals(newVersions)) return false;
+		db.removeMessage(txn, m);
+		storeClientVersions(txn, newVersions);
+		return true;
+	}
+
+	private List<ClientVersion> loadClientVersions(Transaction txn, MessageId m)
+			throws DbException {
+		try {
+			BdfList body = clientHelper.getMessageAsList(txn, m);
+			if (body == null) throw new DbException();
+			return parseClientVersions(body);
+		} catch (FormatException e) {
+			throw new DbException(e);
+		}
+	}
+
+	private List<ClientVersion> parseClientVersions(BdfList body)
+			throws FormatException {
+		int size = body.size();
+		List<ClientVersion> parsed = new ArrayList<>(size);
+		for (int i = 0; i < size; i++) {
+			BdfList cv = body.getList(i);
+			ClientId clientId = new ClientId(cv.getString(0));
+			int clientVersion = cv.getLong(1).intValue();
+			parsed.add(new ClientVersion(clientId, clientVersion));
+		}
+		return parsed;
+	}
+
+	private void clientVersionsUpdated(Transaction txn, Contact c,
+			List<ClientVersion> versions) throws DbException {
+		try {
+			// Find the latest local and remote updates
+			Group g = getContactGroup(c);
+			LatestUpdates latest = findLatestUpdates(txn, g.getId());
+			// Load and parse the latest local update
+			if (latest.local == null) throw new DbException();
+			Update oldLocalUpdate = loadUpdate(txn, latest.local.messageId);
+			List<ClientState> oldLocalStates = oldLocalUpdate.states;
+			long oldLocalUpdateVersion = oldLocalUpdate.updateVersion;
+			// Delete the latest local update
+			db.deleteMessage(txn, latest.local.messageId);
+			db.deleteMessageMetadata(txn, latest.local.messageId);
+			// Store a new local update
+			List<ClientState> newLocalStates =
+					updateStatesFromLocalVersions(oldLocalStates, versions);
+			storeUpdate(txn, g.getId(), newLocalStates,
+					oldLocalUpdateVersion + 1);
+			// Load and parse the latest remote update, if any
+			List<ClientState> remoteStates;
+			if (latest.remote == null) remoteStates = emptyList();
+			else remoteStates = loadUpdate(txn, latest.remote.messageId).states;
+			// Calculate the old and new client visibilities
+			Map<ClientVersion, Visibility> before =
+					getVisibilities(oldLocalStates, remoteStates);
+			Map<ClientVersion, Visibility> after =
+					getVisibilities(newLocalStates, remoteStates);
+			// Call hooks for any visibilities that have changed
+			callVisibilityHooks(txn, c, before, after);
+		} catch (FormatException e) {
+			throw new DbException(e);
+		}
+	}
+
+	private Group getContactGroup(Contact c) {
+		return contactGroupFactory.createContactGroup(CLIENT_ID,
+				CLIENT_VERSION, c);
+	}
+
+	private LatestUpdates findLatestUpdates(Transaction txn, GroupId g)
+			throws DbException, FormatException {
+		Map<MessageId, BdfDictionary> metadata =
+				clientHelper.getMessageMetadataAsDictionary(txn, g);
+		LatestUpdate local = null, remote = null;
+		for (Entry<MessageId, BdfDictionary> e : metadata.entrySet()) {
+			BdfDictionary meta = e.getValue();
+			long updateVersion = meta.getLong(MSG_KEY_UPDATE_VERSION);
+			if (meta.getBoolean(MSG_KEY_LOCAL))
+				local = new LatestUpdate(e.getKey(), updateVersion);
+			else remote = new LatestUpdate(e.getKey(), updateVersion);
+		}
+		return new LatestUpdates(local, remote);
+	}
+
+	private Update loadUpdate(Transaction txn, MessageId m) throws DbException {
+		try {
+			BdfList body = clientHelper.getMessageAsList(txn, m);
+			if (body == null) throw new DbException();
+			return parseUpdate(body);
+		} catch (FormatException e) {
+			throw new DbException(e);
+		}
+	}
+
+	private Update parseUpdate(BdfList body) throws FormatException {
+		List<ClientState> states = parseClientStates(body);
+		long updateVersion = parseUpdateVersion(body);
+		return new Update(states, updateVersion);
+	}
+
+	private List<ClientState> parseClientStates(BdfList body)
+			throws FormatException {
+		// Client states, update version
+		BdfList states = body.getList(0);
+		int size = states.size();
+		List<ClientState> parsed = new ArrayList<>(size);
+		for (int i = 0; i < size; i++)
+			parsed.add(parseClientState(states.getList(i)));
+		return parsed;
+	}
+
+	private ClientState parseClientState(BdfList clientState)
+			throws FormatException {
+		// Client ID, client version, active
+		ClientId clientId = new ClientId(clientState.getString(0));
+		int clientVersion = clientState.getLong(1).intValue();
+		boolean active = clientState.getBoolean(2);
+		return new ClientState(clientId, clientVersion, active);
+	}
+
+	private long parseUpdateVersion(BdfList body) throws FormatException {
+		// Client states, update version
+		return body.getLong(1);
+	}
+
+	private List<ClientState> updateStatesFromLocalVersions(
+			List<ClientState> oldStates, List<ClientVersion> newVersions) {
+		Map<ClientVersion, ClientState> oldMap = new HashMap<>();
+		for (ClientState cs : oldStates) oldMap.put(cs.version, cs);
+		List<ClientState> newStates = new ArrayList<>(newVersions.size());
+		for (ClientVersion newVersion : newVersions) {
+			ClientState oldState = oldMap.get(newVersion);
+			boolean active = oldState != null && oldState.active;
+			newStates.add(new ClientState(newVersion, active));
+		}
+		return newStates;
+	}
+
+	private void storeUpdate(Transaction txn, GroupId g,
+			List<ClientState> states, long updateVersion) throws DbException {
+		try {
+			BdfList body = encodeUpdate(states, updateVersion);
+			long now = clock.currentTimeMillis();
+			Message m = clientHelper.createMessage(g, now, body);
+			BdfDictionary meta = new BdfDictionary();
+			meta.put(MSG_KEY_UPDATE_VERSION, updateVersion);
+			meta.put(MSG_KEY_LOCAL, true);
+			clientHelper.addLocalMessage(txn, m, meta, true);
+		} catch (FormatException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	private BdfList encodeUpdate(List<ClientState> states, long updateVersion) {
+		BdfList encoded = new BdfList();
+		for (ClientState cs : states) encoded.add(encodeClientState(cs));
+		return BdfList.of(encoded, updateVersion);
+	}
+
+	private BdfList encodeClientState(ClientState cs) {
+		return BdfList.of(cs.version.clientId.getString(),
+				cs.version.clientVersion, cs.active);
+	}
+
+	private Map<ClientVersion, Visibility> getVisibilities(
+			List<ClientState> localStates, List<ClientState> remoteStates) {
+		Map<ClientVersion, ClientState> remoteMap = new HashMap<>();
+		for (ClientState remote : remoteStates)
+			remoteMap.put(remote.version, remote);
+		Map<ClientVersion, Visibility> visibilities = new HashMap<>();
+		for (ClientState local : localStates) {
+			ClientState remote = remoteMap.get(local.version);
+			if (remote == null) visibilities.put(local.version, INVISIBLE);
+			else if (remote.active) visibilities.put(local.version, SHARED);
+			else visibilities.put(local.version, VISIBLE);
+		}
+		return visibilities;
+	}
+
+	private void callVisibilityHooks(Transaction txn, Contact c,
+			Map<ClientVersion, Visibility> before,
+			Map<ClientVersion, Visibility> after) throws DbException {
+		Set<ClientVersion> keys = new TreeSet<>();
+		keys.addAll(before.keySet());
+		keys.addAll(after.keySet());
+		for (ClientVersion cv : keys) {
+			Visibility vBefore = before.get(cv), vAfter = after.get(cv);
+			if (vAfter == null) {
+				callVisibilityHook(txn, cv, c, INVISIBLE);
+			} else if (vBefore == null || !vBefore.equals(vAfter)) {
+				callVisibilityHook(txn, cv, c, vAfter);
+			}
+		}
+	}
+
+	private void callVisibilityHook(Transaction txn, ClientVersion cv,
+			Contact c, Visibility v) throws DbException {
+		ClientVersioningHook hook = hooks.get(cv);
+		if (hook != null) hook.onClientVisibilityChanging(txn, c, v);
+	}
+
+	private void storeFirstUpdate(Transaction txn, GroupId g,
+			List<ClientVersion> versions) throws DbException {
+		List<ClientState> states = new ArrayList<>(versions.size());
+		for (ClientVersion cv : versions)
+			states.add(new ClientState(cv, false));
+		storeUpdate(txn, g, states, 1);
+	}
+
+	private Contact getContact(Transaction txn, GroupId g) throws DbException {
+		try {
+			BdfDictionary meta =
+					clientHelper.getGroupMetadataAsDictionary(txn, g);
+			int id = meta.getLong(GROUP_KEY_CONTACT_ID).intValue();
+			return db.getContact(txn, new ContactId(id));
+		} catch (FormatException e) {
+			throw new DbException(e);
+		}
+	}
+
+	private List<ClientState> updateStatesFromRemoteStates(
+			List<ClientState> oldLocalStates, List<ClientState> remoteStates) {
+		Set<ClientVersion> remoteSet = new HashSet<>();
+		for (ClientState remote : remoteStates) remoteSet.add(remote.version);
+		List<ClientState> newLocalStates =
+				new ArrayList<>(oldLocalStates.size());
+		for (ClientState oldState : oldLocalStates) {
+			boolean active = remoteSet.contains(oldState.version);
+			newLocalStates.add(new ClientState(oldState.version, active));
+		}
+		return newLocalStates;
+	}
+
+	private static class Update {
+
+		private final List<ClientState> states;
+		private final long updateVersion;
+
+		private Update(List<ClientState> states, long updateVersion) {
+			this.states = states;
+			this.updateVersion = updateVersion;
+		}
+	}
+
+	private static class LatestUpdate {
+
+		private final MessageId messageId;
+		private final long updateVersion;
+
+		private LatestUpdate(MessageId messageId, long updateVersion) {
+			this.messageId = messageId;
+			this.updateVersion = updateVersion;
+		}
+	}
+
+	private static class LatestUpdates {
+
+		@Nullable
+		private final LatestUpdate local, remote;
+
+		private LatestUpdates(@Nullable LatestUpdate local,
+				@Nullable LatestUpdate remote) {
+			this.local = local;
+			this.remote = remote;
+		}
+	}
+
+	private static class ClientVersion implements Comparable<ClientVersion> {
+
+		private final ClientId clientId;
+		private final int clientVersion;
+
+		private ClientVersion(ClientId clientId, int clientVersion) {
+			this.clientId = clientId;
+			this.clientVersion = clientVersion;
+		}
+
+		@Override
+		public boolean equals(Object o) {
+			if (o instanceof ClientVersion) {
+				ClientVersion cv = (ClientVersion) o;
+				return clientId.equals(cv.clientId)
+						&& clientVersion == cv.clientVersion;
+			}
+			return false;
+		}
+
+		@Override
+		public int hashCode() {
+			return (clientId.hashCode() << 16) + clientVersion;
+		}
+
+		@Override
+		public int compareTo(ClientVersion c) {
+			int compare = clientId.compareTo(c.clientId);
+			if (compare != 0) return compare;
+			return clientVersion - c.clientVersion;
+		}
+	}
+
+	private static class ClientState {
+
+		private final ClientVersion version;
+		private final boolean active;
+
+		private ClientState(ClientVersion version, boolean active) {
+			this.version = version;
+			this.active = active;
+		}
+
+		private ClientState(ClientId clientId, int clientVersion,
+				boolean active) {
+			this(new ClientVersion(clientId, clientVersion), active);
+		}
+
+		@Override
+		public boolean equals(Object o) {
+			if (o instanceof ClientState) {
+				ClientState cs = (ClientState) o;
+				return version.equals(cs.version) && active == cs.active;
+			}
+			return false;
+		}
+
+		@Override
+		public int hashCode() {
+			return version.hashCode();
+		}
+	}
+}
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/sync/ClientVersioningValidator.java b/bramble-core/src/main/java/org/briarproject/bramble/sync/ClientVersioningValidator.java
new file mode 100644
index 0000000000..2795523a14
--- /dev/null
+++ b/bramble-core/src/main/java/org/briarproject/bramble/sync/ClientVersioningValidator.java
@@ -0,0 +1,59 @@
+package org.briarproject.bramble.sync;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.client.BdfMessageContext;
+import org.briarproject.bramble.api.client.BdfMessageValidator;
+import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.data.BdfDictionary;
+import org.briarproject.bramble.api.data.BdfList;
+import org.briarproject.bramble.api.data.MetadataEncoder;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.sync.Group;
+import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.bramble.api.system.Clock;
+
+import javax.annotation.concurrent.Immutable;
+
+import static org.briarproject.bramble.api.sync.ClientId.MAX_CLIENT_ID_LENGTH;
+import static org.briarproject.bramble.sync.ClientVersioningConstants.MSG_KEY_LOCAL;
+import static org.briarproject.bramble.sync.ClientVersioningConstants.MSG_KEY_UPDATE_VERSION;
+import static org.briarproject.bramble.util.ValidationUtils.checkLength;
+import static org.briarproject.bramble.util.ValidationUtils.checkSize;
+
+@Immutable
+@NotNullByDefault
+class ClientVersioningValidator extends BdfMessageValidator {
+
+	ClientVersioningValidator(ClientHelper clientHelper,
+			MetadataEncoder metadataEncoder, Clock clock) {
+		super(clientHelper, metadataEncoder, clock);
+	}
+
+	@Override
+	protected BdfMessageContext validateMessage(Message m, Group g,
+			BdfList body) throws FormatException {
+		// Client states, update version
+		checkSize(body, 2);
+		// Client states
+		BdfList states = body.getList(0);
+		int size = states.size();
+		for (int i = 0; i < size; i++) {
+			BdfList clientState = states.getList(i);
+			// Client ID, client version, active
+			checkSize(clientState, 3);
+			String clientId = clientState.getString(0);
+			checkLength(clientId, 1, MAX_CLIENT_ID_LENGTH);
+			int clientVersion = clientState.getLong(1).intValue();
+			if (clientVersion < 0) throw new FormatException();
+			boolean active = clientState.getBoolean(2);
+		}
+		// Update version
+		long updateVersion = body.getLong(1);
+		if (updateVersion < 0) throw new FormatException();
+		// Return the metadata
+		BdfDictionary meta = new BdfDictionary();
+		meta.put(MSG_KEY_UPDATE_VERSION, updateVersion);
+		meta.put(MSG_KEY_LOCAL, false);
+		return new BdfMessageContext(meta);
+	}
+}
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/sync/SyncModule.java b/bramble-core/src/main/java/org/briarproject/bramble/sync/SyncModule.java
index ca6ec897af..0acf9e4e80 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/sync/SyncModule.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/sync/SyncModule.java
@@ -1,12 +1,16 @@
 package org.briarproject.bramble.sync;
 
 import org.briarproject.bramble.PoliteExecutor;
+import org.briarproject.bramble.api.client.ClientHelper;
+import org.briarproject.bramble.api.contact.ContactManager;
 import org.briarproject.bramble.api.crypto.CryptoComponent;
 import org.briarproject.bramble.api.crypto.CryptoExecutor;
+import org.briarproject.bramble.api.data.MetadataEncoder;
 import org.briarproject.bramble.api.db.DatabaseComponent;
 import org.briarproject.bramble.api.db.DatabaseExecutor;
 import org.briarproject.bramble.api.event.EventBus;
 import org.briarproject.bramble.api.lifecycle.LifecycleManager;
+import org.briarproject.bramble.api.sync.ClientVersioningManager;
 import org.briarproject.bramble.api.sync.GroupFactory;
 import org.briarproject.bramble.api.sync.MessageFactory;
 import org.briarproject.bramble.api.sync.RecordReaderFactory;
@@ -23,12 +27,18 @@ import javax.inject.Singleton;
 import dagger.Module;
 import dagger.Provides;
 
+import static org.briarproject.bramble.api.sync.ClientVersioningManager.CLIENT_ID;
+
 @Module
 public class SyncModule {
 
 	public static class EagerSingletons {
 		@Inject
 		ValidationManager validationManager;
+		@Inject
+		ClientVersioningManager clientVersioningManager;
+		@Inject
+		ClientVersioningValidator clientVersioningValidator;
 	}
 
 	/**
@@ -90,4 +100,29 @@ public class SyncModule {
 		return new PoliteExecutor("ValidationExecutor", cryptoExecutor,
 				MAX_CONCURRENT_VALIDATION_TASKS);
 	}
+
+	@Provides
+	@Singleton
+	ClientVersioningManager provideClientVersioningManager(
+			ClientVersioningManagerImpl clientVersioningManager,
+			LifecycleManager lifecycleManager, ContactManager contactManager,
+			ValidationManager validationManager) {
+		lifecycleManager.registerClient(clientVersioningManager);
+		lifecycleManager.registerService(clientVersioningManager);
+		contactManager.registerContactHook(clientVersioningManager);
+		validationManager.registerIncomingMessageHook(CLIENT_ID,
+				clientVersioningManager);
+		return clientVersioningManager;
+	}
+
+	@Provides
+	@Singleton
+	ClientVersioningValidator provideClientVersioningValidator(
+			ClientHelper clientHelper, MetadataEncoder metadataEncoder,
+			Clock clock, ValidationManager validationManager) {
+		ClientVersioningValidator validator = new ClientVersioningValidator(
+				clientHelper, metadataEncoder, clock);
+		validationManager.registerMessageValidator(CLIENT_ID, validator);
+		return validator;
+	}
 }
diff --git a/briar-core/src/test/java/org/briarproject/briar/test/BriarIntegrationTest.java b/briar-core/src/test/java/org/briarproject/briar/test/BriarIntegrationTest.java
index 3c2138b613..ebd3f22383 100644
--- a/briar-core/src/test/java/org/briarproject/briar/test/BriarIntegrationTest.java
+++ b/briar-core/src/test/java/org/briarproject/briar/test/BriarIntegrationTest.java
@@ -159,10 +159,9 @@ public abstract class BriarIntegrationTest<C extends BriarIntegrationTestCompone
 		deliveryWaiter = new Waiter();
 
 		startLifecycles();
-
 		getDefaultIdentities();
-		addDefaultContacts();
 		listenToEvents();
+		addDefaultContacts();
 	}
 
 	abstract protected void createComponents();
@@ -187,7 +186,7 @@ public abstract class BriarIntegrationTest<C extends BriarIntegrationTestCompone
 	}
 
 	private void startLifecycles() throws InterruptedException {
-		// Start the lifecycle manager and wait for it to finish
+		// Start the lifecycle manager and wait for it to finish starting
 		lifecycleManager0 = c0.getLifecycleManager();
 		lifecycleManager1 = c1.getLifecycleManager();
 		lifecycleManager2 = c2.getLifecycleManager();
@@ -234,7 +233,7 @@ public abstract class BriarIntegrationTest<C extends BriarIntegrationTestCompone
 		author2 = identityManager2.getLocalAuthor();
 	}
 
-	protected void addDefaultContacts() throws DbException {
+	protected void addDefaultContacts() throws Exception {
 		contactId1From0 = contactManager0
 				.addContact(author1, author0.getId(), getSecretKey(),
 						clock.currentTimeMillis(), true, true, true);
@@ -251,15 +250,25 @@ public abstract class BriarIntegrationTest<C extends BriarIntegrationTestCompone
 				.addContact(author0, author2.getId(), getSecretKey(),
 						clock.currentTimeMillis(), true, true, true);
 		contact0From2 = contactManager2.getContact(contactId0From2);
+
+		// Sync initial client versioning updates
+		sync0To1(1, true);
+		sync0To2(1, true);
+		sync1To0(1, true);
+		sync2To0(1, true);
 	}
 
-	protected void addContacts1And2() throws DbException {
+	protected void addContacts1And2() throws Exception {
 		contactId2From1 = contactManager1
 				.addContact(author2, author1.getId(), getSecretKey(),
 						clock.currentTimeMillis(), true, true, true);
 		contactId1From2 = contactManager2
 				.addContact(author1, author2.getId(), getSecretKey(),
 						clock.currentTimeMillis(), true, true, true);
+
+		// Sync initial client versioning updates
+		sync1To2(1, true);
+		sync2To1(1, true);
 	}
 
 	@After
-- 
GitLab