diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/contact/PendingContactState.java b/bramble-api/src/main/java/org/briarproject/bramble/api/contact/PendingContactState.java
index 2d8e8812d6955cd8f83e944cfdd21428966bc64d..9fdb1344289b80c79ee2b18ad51a40bc7106d6fb 100644
--- a/bramble-api/src/main/java/org/briarproject/bramble/api/contact/PendingContactState.java
+++ b/bramble-api/src/main/java/org/briarproject/bramble/api/contact/PendingContactState.java
@@ -3,7 +3,7 @@ package org.briarproject.bramble.api.contact;
 public enum PendingContactState {
 
 	WAITING_FOR_CONNECTION,
-	CONNECTED,
+	CONNECTING,
 	ADDING_CONTACT,
 	FAILED
 }
diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/RendezvousPoller.java b/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/RendezvousPoller.java
new file mode 100644
index 0000000000000000000000000000000000000000..a4eb0dcb6745db8dc33229873608ac99010d9be2
--- /dev/null
+++ b/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/RendezvousPoller.java
@@ -0,0 +1,12 @@
+package org.briarproject.bramble.api.rendezvous;
+
+import org.briarproject.bramble.api.contact.PendingContactId;
+
+/**
+ * Interface for the poller that makes rendezvous connections to pending
+ * contacts.
+ */
+public interface RendezvousPoller {
+
+	long getLastPollTime(PendingContactId p);
+}
diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/event/RendezvousFailedEvent.java b/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/event/RendezvousFailedEvent.java
deleted file mode 100644
index 0530db1e258f97655791adf0e02571358b4bb711..0000000000000000000000000000000000000000
--- a/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/event/RendezvousFailedEvent.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package org.briarproject.bramble.api.rendezvous.event;
-
-import org.briarproject.bramble.api.contact.PendingContactId;
-import org.briarproject.bramble.api.event.Event;
-import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
-
-import javax.annotation.concurrent.Immutable;
-
-/**
- * An event that is broadcast when a rendezvous with a pending contact fails.
- */
-@Immutable
-@NotNullByDefault
-public class RendezvousFailedEvent extends Event {
-
-	private final PendingContactId pendingContactId;
-
-	public RendezvousFailedEvent(PendingContactId pendingContactId) {
-		this.pendingContactId = pendingContactId;
-	}
-
-	public PendingContactId getPendingContactId() {
-		return pendingContactId;
-	}
-}
diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/event/RendezvousPollEvent.java b/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/event/RendezvousPollEvent.java
new file mode 100644
index 0000000000000000000000000000000000000000..0281a9017aa0f0c27c07dc63d7257f09dfe4d24d
--- /dev/null
+++ b/bramble-api/src/main/java/org/briarproject/bramble/api/rendezvous/event/RendezvousPollEvent.java
@@ -0,0 +1,36 @@
+package org.briarproject.bramble.api.rendezvous.event;
+
+import org.briarproject.bramble.api.contact.PendingContactId;
+import org.briarproject.bramble.api.event.Event;
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.plugin.TransportId;
+
+import java.util.Collection;
+
+import javax.annotation.concurrent.Immutable;
+
+/**
+ * An event that is broadcast when a transport plugin is polled for connections
+ * to one or more pending contacts.
+ */
+@Immutable
+@NotNullByDefault
+public class RendezvousPollEvent extends Event {
+
+	private final TransportId transportId;
+	private final Collection<PendingContactId> pendingContacts;
+
+	public RendezvousPollEvent(TransportId transportId,
+			Collection<PendingContactId> pendingContacts) {
+		this.transportId = transportId;
+		this.pendingContacts = pendingContacts;
+	}
+
+	public TransportId getTransportId() {
+		return transportId;
+	}
+
+	public Collection<PendingContactId> getPendingContacts() {
+		return pendingContacts;
+	}
+}
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java b/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java
index 5c9ec6fa53d543770835ec4acffe2abd62eae3bf..f3138fd75fc6c6065f29d23430e93a7dbd4312eb 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java
@@ -17,7 +17,6 @@ import org.briarproject.bramble.api.db.DbException;
 import org.briarproject.bramble.api.db.NoSuchContactException;
 import org.briarproject.bramble.api.db.Transaction;
 import org.briarproject.bramble.api.event.Event;
-import org.briarproject.bramble.api.event.EventBus;
 import org.briarproject.bramble.api.event.EventListener;
 import org.briarproject.bramble.api.identity.Author;
 import org.briarproject.bramble.api.identity.AuthorId;
@@ -25,25 +24,20 @@ import org.briarproject.bramble.api.identity.AuthorInfo;
 import org.briarproject.bramble.api.identity.IdentityManager;
 import org.briarproject.bramble.api.identity.LocalAuthor;
 import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
-import org.briarproject.bramble.api.rendezvous.event.RendezvousConnectionClosedEvent;
-import org.briarproject.bramble.api.rendezvous.event.RendezvousConnectionOpenedEvent;
-import org.briarproject.bramble.api.rendezvous.event.RendezvousFailedEvent;
 import org.briarproject.bramble.api.transport.KeyManager;
 
 import java.security.GeneralSecurityException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.CopyOnWriteArrayList;
 
 import javax.annotation.Nullable;
 import javax.annotation.concurrent.ThreadSafe;
 import javax.inject.Inject;
 
-import static org.briarproject.bramble.api.contact.PendingContactState.ADDING_CONTACT;
-import static org.briarproject.bramble.api.contact.PendingContactState.FAILED;
 import static org.briarproject.bramble.api.contact.PendingContactState.WAITING_FOR_CONNECTION;
 import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_AUTHOR_NAME_LENGTH;
 import static org.briarproject.bramble.api.identity.AuthorInfo.Status.OURSELVES;
@@ -60,23 +54,20 @@ class ContactManagerImpl implements ContactManager, EventListener {
 	private final KeyManager keyManager;
 	private final IdentityManager identityManager;
 	private final PendingContactFactory pendingContactFactory;
-	private final EventBus eventBus;
 
 	private final List<ContactHook> hooks = new CopyOnWriteArrayList<>();
-	private final ConcurrentMap<PendingContactId, PendingContactState> states =
+	private final Map<PendingContactId, PendingContactState> states =
 			new ConcurrentHashMap<>();
 
 	@Inject
 	ContactManagerImpl(DatabaseComponent db,
 			KeyManager keyManager,
 			IdentityManager identityManager,
-			PendingContactFactory pendingContactFactory,
-			EventBus eventBus) {
+			PendingContactFactory pendingContactFactory) {
 		this.db = db;
 		this.keyManager = keyManager;
 		this.identityManager = identityManager;
 		this.pendingContactFactory = pendingContactFactory;
-		this.eventBus = eventBus;
 	}
 
 	@Override
@@ -156,7 +147,7 @@ class ContactManagerImpl implements ContactManager, EventListener {
 		} finally {
 			db.endTransaction(txn);
 		}
-		setState(p.getId(), WAITING_FOR_CONNECTION);
+		states.put(p.getId(), WAITING_FOR_CONNECTION);
 		return p;
 	}
 
@@ -286,46 +277,10 @@ class ContactManagerImpl implements ContactManager, EventListener {
 
 	@Override
 	public void eventOccurred(Event e) {
-		if (e instanceof RendezvousConnectionOpenedEvent) {
-			RendezvousConnectionOpenedEvent r =
-					(RendezvousConnectionOpenedEvent) e;
-			setStateConnected(r.getPendingContactId());
-		} else if (e instanceof RendezvousConnectionClosedEvent) {
-			RendezvousConnectionClosedEvent r =
-					(RendezvousConnectionClosedEvent) e;
-			// We're only interested in failures - if the rendezvous succeeds
-			// the pending contact will be removed
-			if (!r.isSuccess()) setStateDisconnected(r.getPendingContactId());
-		} else if (e instanceof RendezvousFailedEvent) {
-			RendezvousFailedEvent r = (RendezvousFailedEvent) e;
-			setState(r.getPendingContactId(), FAILED);
-		}
-	}
-
-	/**
-	 * Sets the state of the given pending contact and broadcasts an event.
-	 */
-	private void setState(PendingContactId p, PendingContactState state) {
-		states.put(p, state);
-		eventBus.broadcast(new PendingContactStateChangedEvent(p, state));
-	}
-
-	private void setStateConnected(PendingContactId p) {
-		// Set the state to ADDING_CONTACT if there's no current state or the
-		// current state is WAITING_FOR_CONNECTION
-		if (states.putIfAbsent(p, ADDING_CONTACT) == null ||
-				states.replace(p, WAITING_FOR_CONNECTION, ADDING_CONTACT)) {
-			eventBus.broadcast(new PendingContactStateChangedEvent(p,
-					ADDING_CONTACT));
-		}
-	}
-
-	private void setStateDisconnected(PendingContactId p) {
-		// Set the state to WAITING_FOR_CONNECTION if the current state is
-		// ADDING_CONTACT
-		if (states.replace(p, ADDING_CONTACT, WAITING_FOR_CONNECTION)) {
-			eventBus.broadcast(new PendingContactStateChangedEvent(p,
-					WAITING_FOR_CONNECTION));
+		if (e instanceof PendingContactStateChangedEvent) {
+			PendingContactStateChangedEvent p =
+					(PendingContactStateChangedEvent) e;
+			states.put(p.getId(), p.getPendingContactState());
 		}
 	}
 }
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousModule.java b/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousModule.java
index 41bf14f66a09b74cb40eb47591e2a0b03ab57b8a..2d868f881d873bba95f4c0a557b60283cf77e3dc 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousModule.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousModule.java
@@ -2,6 +2,7 @@ package org.briarproject.bramble.rendezvous;
 
 import org.briarproject.bramble.api.event.EventBus;
 import org.briarproject.bramble.api.lifecycle.LifecycleManager;
+import org.briarproject.bramble.api.rendezvous.RendezvousPoller;
 
 import javax.inject.Inject;
 import javax.inject.Singleton;
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousPoller.java b/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousPoller.java
deleted file mode 100644
index 4ecfc32a6aaa7eb863ee97b029348c9266c1eb7f..0000000000000000000000000000000000000000
--- a/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousPoller.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package org.briarproject.bramble.rendezvous;
-
-/**
- * Empty interface for injecting the rendezvous poller.
- */
-interface RendezvousPoller {
-}
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousPollerImpl.java b/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousPollerImpl.java
index b0d0c34619f387a5e09a774995f45d21a75d5cd1..6f5ec72b70fed1c1b1306dd3b51510b8e81ba8be 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousPollerImpl.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/rendezvous/RendezvousPollerImpl.java
@@ -4,8 +4,10 @@ import org.briarproject.bramble.PoliteExecutor;
 import org.briarproject.bramble.api.Pair;
 import org.briarproject.bramble.api.contact.PendingContact;
 import org.briarproject.bramble.api.contact.PendingContactId;
+import org.briarproject.bramble.api.contact.PendingContactState;
 import org.briarproject.bramble.api.contact.event.PendingContactAddedEvent;
 import org.briarproject.bramble.api.contact.event.PendingContactRemovedEvent;
+import org.briarproject.bramble.api.contact.event.PendingContactStateChangedEvent;
 import org.briarproject.bramble.api.crypto.KeyPair;
 import org.briarproject.bramble.api.crypto.SecretKey;
 import org.briarproject.bramble.api.crypto.TransportCrypto;
@@ -34,7 +36,10 @@ import org.briarproject.bramble.api.plugin.event.TransportEnabledEvent;
 import org.briarproject.bramble.api.properties.TransportProperties;
 import org.briarproject.bramble.api.rendezvous.KeyMaterialSource;
 import org.briarproject.bramble.api.rendezvous.RendezvousEndpoint;
-import org.briarproject.bramble.api.rendezvous.event.RendezvousFailedEvent;
+import org.briarproject.bramble.api.rendezvous.RendezvousPoller;
+import org.briarproject.bramble.api.rendezvous.event.RendezvousConnectionClosedEvent;
+import org.briarproject.bramble.api.rendezvous.event.RendezvousConnectionOpenedEvent;
+import org.briarproject.bramble.api.rendezvous.event.RendezvousPollEvent;
 import org.briarproject.bramble.api.system.Clock;
 import org.briarproject.bramble.api.system.Scheduler;
 
@@ -45,6 +50,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Executor;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -58,6 +64,9 @@ import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.logging.Level.INFO;
 import static java.util.logging.Level.WARNING;
 import static java.util.logging.Logger.getLogger;
+import static org.briarproject.bramble.api.contact.PendingContactState.ADDING_CONTACT;
+import static org.briarproject.bramble.api.contact.PendingContactState.FAILED;
+import static org.briarproject.bramble.api.contact.PendingContactState.WAITING_FOR_CONNECTION;
 import static org.briarproject.bramble.api.nullsafety.NullSafety.requireNull;
 import static org.briarproject.bramble.rendezvous.RendezvousConstants.POLLING_INTERVAL_MS;
 import static org.briarproject.bramble.rendezvous.RendezvousConstants.RENDEZVOUS_TIMEOUT_MS;
@@ -81,6 +90,9 @@ class RendezvousPollerImpl implements RendezvousPoller, Service, EventListener {
 	private final Clock clock;
 
 	private final AtomicBoolean used = new AtomicBoolean(false);
+	private final Map<PendingContactId, Long> lastPollTimes =
+			new ConcurrentHashMap<>();
+
 	// Executor that runs one task at a time
 	private final Executor worker;
 	// The following fields are only accessed on the worker
@@ -113,6 +125,12 @@ class RendezvousPollerImpl implements RendezvousPoller, Service, EventListener {
 		worker = new PoliteExecutor("RendezvousPoller", ioExecutor, 1);
 	}
 
+	@Override
+	public long getLastPollTime(PendingContactId p) {
+		Long time = lastPollTimes.get(p);
+		return time == null ? 0 : time;
+	}
+
 	@Override
 	public void startService() throws ServiceException {
 		if (used.getAndSet(true)) throw new IllegalStateException();
@@ -140,8 +158,10 @@ class RendezvousPollerImpl implements RendezvousPoller, Service, EventListener {
 	private void addPendingContact(PendingContact p) {
 		long now = clock.currentTimeMillis();
 		long expiry = p.getTimestamp() + RENDEZVOUS_TIMEOUT_MS;
-		if (expiry <= now) {
-			eventBus.broadcast(new RendezvousFailedEvent(p.getId()));
+		if (expiry > now) {
+			broadcastState(p.getId(), WAITING_FOR_CONNECTION);
+		} else {
+			broadcastState(p.getId(), FAILED);
 			return;
 		}
 		try {
@@ -168,6 +188,10 @@ class RendezvousPollerImpl implements RendezvousPoller, Service, EventListener {
 		}
 	}
 
+	private void broadcastState(PendingContactId p, PendingContactState state) {
+		eventBus.broadcast(new PendingContactStateChangedEvent(p, state));
+	}
+
 	@Nullable
 	private RendezvousEndpoint createEndpoint(DuplexPlugin plugin,
 			PendingContactId p, CryptoState cs) {
@@ -195,7 +219,7 @@ class RendezvousPollerImpl implements RendezvousPoller, Service, EventListener {
 		}
 		for (PendingContactId p : expired) {
 			removePendingContact(p);
-			eventBus.broadcast(new RendezvousFailedEvent(p));
+			broadcastState(p, FAILED);
 		}
 	}
 
@@ -203,6 +227,7 @@ class RendezvousPollerImpl implements RendezvousPoller, Service, EventListener {
 	private void removePendingContact(PendingContactId p) {
 		// We can come here twice if a pending contact expires and is removed
 		if (cryptoStates.remove(p) == null) return;
+		lastPollTimes.remove(p);
 		for (PluginState ps : pluginStates.values()) {
 			RendezvousEndpoint endpoint = ps.endpoints.remove(p);
 			if (endpoint != null) tryToClose(endpoint, LOG, INFO);
@@ -211,16 +236,22 @@ class RendezvousPollerImpl implements RendezvousPoller, Service, EventListener {
 
 	// Worker
 	private void poll(PluginState ps) {
+		if (ps.endpoints.isEmpty()) return;
+		TransportId t = ps.plugin.getId();
 		List<Pair<TransportProperties, ConnectionHandler>> properties =
 				new ArrayList<>();
 		for (Entry<PendingContactId, RendezvousEndpoint> e :
 				ps.endpoints.entrySet()) {
 			TransportProperties props =
 					e.getValue().getRemoteTransportProperties();
-			Handler h = new Handler(e.getKey(), ps.plugin.getId(), false);
+			Handler h = new Handler(e.getKey(), t, false);
 			properties.add(new Pair<>(props, h));
 		}
-		if (!properties.isEmpty()) ps.plugin.poll(properties);
+		List<PendingContactId> polled = new ArrayList<>(ps.endpoints.keySet());
+		long now = clock.currentTimeMillis();
+		for (PendingContactId p : polled) lastPollTimes.put(p, now);
+		eventBus.broadcast(new RendezvousPollEvent(t, polled));
+		ps.plugin.poll(properties);
 	}
 
 	@Override
@@ -241,6 +272,14 @@ class RendezvousPollerImpl implements RendezvousPoller, Service, EventListener {
 		} else if (e instanceof TransportDisabledEvent) {
 			TransportDisabledEvent t = (TransportDisabledEvent) e;
 			removeTransportAsync(t.getTransportId());
+		} else if (e instanceof RendezvousConnectionOpenedEvent) {
+			RendezvousConnectionOpenedEvent r =
+					(RendezvousConnectionOpenedEvent) e;
+			connectionOpenedAsync(r.getPendingContactId());
+		} else if (e instanceof RendezvousConnectionClosedEvent) {
+			RendezvousConnectionClosedEvent r =
+					(RendezvousConnectionClosedEvent) e;
+			if (!r.isSuccess()) connectionFailedAsync(r.getPendingContactId());
 		}
 	}
 
@@ -257,9 +296,13 @@ class RendezvousPollerImpl implements RendezvousPoller, Service, EventListener {
 		for (PluginState ps : pluginStates.values()) {
 			RendezvousEndpoint endpoint = ps.endpoints.get(p);
 			if (endpoint != null) {
+				TransportId t = ps.plugin.getId();
 				TransportProperties props =
 						endpoint.getRemoteTransportProperties();
-				Handler h = new Handler(p, ps.plugin.getId(), false);
+				Handler h = new Handler(p, t, false);
+				lastPollTimes.put(p, clock.currentTimeMillis());
+				eventBus.broadcast(
+						new RendezvousPollEvent(t, singletonList(p)));
 				ps.plugin.poll(singletonList(new Pair<>(props, h)));
 			}
 		}
@@ -307,6 +350,29 @@ class RendezvousPollerImpl implements RendezvousPoller, Service, EventListener {
 		}
 	}
 
+	@EventExecutor
+	private void connectionOpenedAsync(PendingContactId p) {
+		worker.execute(() -> connectionOpened(p));
+	}
+
+	// Worker
+	private void connectionOpened(PendingContactId p) {
+		// Check that the pending contact hasn't expired
+		if (cryptoStates.containsKey(p)) broadcastState(p, ADDING_CONTACT);
+	}
+
+	@EventExecutor
+	private void connectionFailedAsync(PendingContactId p) {
+		worker.execute(() -> connectionFailed(p));
+	}
+
+	// Worker
+	private void connectionFailed(PendingContactId p) {
+		// Check that the pending contact hasn't expired
+		if (cryptoStates.containsKey(p))
+			broadcastState(p, WAITING_FOR_CONNECTION);
+	}
+
 	private static class PluginState {
 
 		private final DuplexPlugin plugin;
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/contact/ContactManagerImplTest.java b/bramble-core/src/test/java/org/briarproject/bramble/contact/ContactManagerImplTest.java
index b0a50ec161be7868f6faa56f0e9fdcf5addd6cb2..c828f2698a9349ede13afd1cc76c7d7e469faba2 100644
--- a/bramble-core/src/test/java/org/briarproject/bramble/contact/ContactManagerImplTest.java
+++ b/bramble-core/src/test/java/org/briarproject/bramble/contact/ContactManagerImplTest.java
@@ -5,26 +5,20 @@ import org.briarproject.bramble.api.contact.Contact;
 import org.briarproject.bramble.api.contact.ContactId;
 import org.briarproject.bramble.api.contact.PendingContact;
 import org.briarproject.bramble.api.contact.PendingContactState;
-import org.briarproject.bramble.api.contact.event.PendingContactStateChangedEvent;
 import org.briarproject.bramble.api.crypto.KeyPair;
 import org.briarproject.bramble.api.crypto.SecretKey;
 import org.briarproject.bramble.api.db.DatabaseComponent;
 import org.briarproject.bramble.api.db.DbException;
 import org.briarproject.bramble.api.db.NoSuchContactException;
 import org.briarproject.bramble.api.db.Transaction;
-import org.briarproject.bramble.api.event.EventBus;
 import org.briarproject.bramble.api.identity.Author;
 import org.briarproject.bramble.api.identity.AuthorId;
 import org.briarproject.bramble.api.identity.AuthorInfo;
 import org.briarproject.bramble.api.identity.IdentityManager;
 import org.briarproject.bramble.api.identity.LocalAuthor;
-import org.briarproject.bramble.api.rendezvous.event.RendezvousConnectionClosedEvent;
-import org.briarproject.bramble.api.rendezvous.event.RendezvousConnectionOpenedEvent;
-import org.briarproject.bramble.api.rendezvous.event.RendezvousFailedEvent;
 import org.briarproject.bramble.api.transport.KeyManager;
 import org.briarproject.bramble.test.BrambleMockTestCase;
 import org.briarproject.bramble.test.DbExpectations;
-import org.briarproject.bramble.test.PredicateMatcher;
 import org.jmock.Expectations;
 import org.junit.Before;
 import org.junit.Test;
@@ -35,8 +29,6 @@ import java.util.Random;
 import static java.util.Collections.emptyList;
 import static java.util.Collections.singletonList;
 import static org.briarproject.bramble.api.contact.HandshakeLinkConstants.BASE32_LINK_BYTES;
-import static org.briarproject.bramble.api.contact.PendingContactState.ADDING_CONTACT;
-import static org.briarproject.bramble.api.contact.PendingContactState.FAILED;
 import static org.briarproject.bramble.api.contact.PendingContactState.WAITING_FOR_CONNECTION;
 import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_AUTHOR_NAME_LENGTH;
 import static org.briarproject.bramble.api.identity.AuthorInfo.Status.OURSELVES;
@@ -65,7 +57,6 @@ public class ContactManagerImplTest extends BrambleMockTestCase {
 			context.mock(IdentityManager.class);
 	private final PendingContactFactory pendingContactFactory =
 			context.mock(PendingContactFactory.class);
-	private final EventBus eventBus = context.mock(EventBus.class);
 
 	private final Author remote = getAuthor();
 	private final LocalAuthor localAuthor = getLocalAuthor();
@@ -85,7 +76,7 @@ public class ContactManagerImplTest extends BrambleMockTestCase {
 	@Before
 	public void setUp() {
 		contactManager = new ContactManagerImpl(db, keyManager,
-				identityManager, pendingContactFactory, eventBus);
+				identityManager, pendingContactFactory);
 	}
 
 	@Test
@@ -328,143 +319,4 @@ public class ContactManagerImplTest extends BrambleMockTestCase {
 		assertEquals(WAITING_FOR_CONNECTION, pair.getSecond());
 	}
 
-	@Test
-	public void testPendingContactExpiresBeforeConnection() {
-		// The pending contact expires - the FAILED state is broadcast
-		context.checking(new Expectations() {{
-			oneOf(eventBus).broadcast(with(new PredicateMatcher<>(
-					PendingContactStateChangedEvent.class, e ->
-					e.getPendingContactState() == FAILED)));
-		}});
-		contactManager.eventOccurred(new RendezvousFailedEvent(
-				pendingContact.getId()));
-		context.assertIsSatisfied();
-
-		// A rendezvous connection is opened - no state is broadcast
-		contactManager.eventOccurred(new RendezvousConnectionOpenedEvent(
-				pendingContact.getId()));
-		context.assertIsSatisfied();
-
-		// The rendezvous connection fails - no state is broadcast
-		contactManager.eventOccurred(new RendezvousConnectionClosedEvent(
-				pendingContact.getId(), false));
-	}
-
-	@Test
-	public void testPendingContactExpiresDuringFailedConnection() {
-		// A rendezvous connection is opened - the ADDING_CONTACT state is
-		// broadcast
-		context.checking(new Expectations() {{
-			oneOf(eventBus).broadcast(with(new PredicateMatcher<>(
-					PendingContactStateChangedEvent.class, e ->
-					e.getPendingContactState() == ADDING_CONTACT)));
-		}});
-
-		contactManager.eventOccurred(new RendezvousConnectionOpenedEvent(
-				pendingContact.getId()));
-		context.assertIsSatisfied();
-
-		// The pending contact expires - the FAILED state is broadcast
-		context.checking(new Expectations() {{
-			oneOf(eventBus).broadcast(with(new PredicateMatcher<>(
-					PendingContactStateChangedEvent.class, e ->
-					e.getPendingContactState() == FAILED)));
-		}});
-
-		contactManager.eventOccurred(new RendezvousFailedEvent(
-				pendingContact.getId()));
-		context.assertIsSatisfied();
-
-		// The rendezvous connection fails - no state is broadcast
-		contactManager.eventOccurred(new RendezvousConnectionClosedEvent(
-				pendingContact.getId(), false));
-	}
-
-	@Test
-	public void testPendingContactExpiresDuringSuccessfulConnection()
-			throws Exception {
-		Transaction txn = new Transaction(null, false);
-
-		// A rendezvous connection is opened - the ADDING_CONTACT state is
-		// broadcast
-		context.checking(new Expectations() {{
-			oneOf(eventBus).broadcast(with(new PredicateMatcher<>(
-					PendingContactStateChangedEvent.class, e ->
-					e.getPendingContactState() == ADDING_CONTACT)));
-		}});
-
-		contactManager.eventOccurred(new RendezvousConnectionOpenedEvent(
-				pendingContact.getId()));
-		context.assertIsSatisfied();
-
-		// The pending contact expires - the FAILED state is broadcast
-		context.checking(new Expectations() {{
-			oneOf(eventBus).broadcast(with(new PredicateMatcher<>(
-					PendingContactStateChangedEvent.class, e ->
-					e.getPendingContactState() == FAILED)));
-		}});
-
-		contactManager.eventOccurred(new RendezvousFailedEvent(
-				pendingContact.getId()));
-		context.assertIsSatisfied();
-
-		// The pending contact is converted to a contact - no state is broadcast
-		context.checking(new DbExpectations() {{
-			oneOf(db).getPendingContact(txn, pendingContact.getId());
-			will(returnValue(pendingContact));
-			oneOf(db).removePendingContact(txn, pendingContact.getId());
-			oneOf(db).addContact(txn, remote, local,
-					pendingContact.getPublicKey(), verified);
-			will(returnValue(contactId));
-			oneOf(db).setContactAlias(txn, contactId,
-					pendingContact.getAlias());
-			oneOf(identityManager).getHandshakeKeys(txn);
-			will(returnValue(handshakeKeyPair));
-			oneOf(keyManager).addContact(txn, contactId,
-					pendingContact.getPublicKey(), handshakeKeyPair);
-			oneOf(keyManager).addRotationKeys(txn, contactId, rootKey,
-					timestamp, alice, active);
-			oneOf(db).getContact(txn, contactId);
-			will(returnValue(contact));
-		}});
-
-		contactManager.addContact(txn, pendingContact.getId(), remote,
-				local, rootKey, timestamp, alice, verified, active);
-		context.assertIsSatisfied();
-
-		// The rendezvous connection succeeds - no state is broadcast
-		contactManager.eventOccurred(new RendezvousConnectionClosedEvent(
-				pendingContact.getId(), true));
-	}
-
-	@Test
-	public void testPendingContactRemovedDuringFailedConnection()
-			throws Exception {
-		Transaction txn = new Transaction(null, false);
-
-		// A rendezvous connection is opened - the ADDING_CONTACT state is
-		// broadcast
-		context.checking(new Expectations() {{
-			oneOf(eventBus).broadcast(with(new PredicateMatcher<>(
-					PendingContactStateChangedEvent.class, e ->
-					e.getPendingContactState() == ADDING_CONTACT)));
-		}});
-
-		contactManager.eventOccurred(new RendezvousConnectionOpenedEvent(
-				pendingContact.getId()));
-		context.assertIsSatisfied();
-
-		// The pending contact is removed - no state is broadcast
-		context.checking(new DbExpectations() {{
-			oneOf(db).transaction(with(false), withDbRunnable(txn));
-			oneOf(db).removePendingContact(txn, pendingContact.getId());
-		}});
-
-		contactManager.removePendingContact(pendingContact.getId());
-		context.assertIsSatisfied();
-
-		// The rendezvous connection fails - no state is broadcast
-		contactManager.eventOccurred(new RendezvousConnectionClosedEvent(
-				pendingContact.getId(), false));
-	}
 }
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/rendezvous/RendezvousPollerImplTest.java b/bramble-core/src/test/java/org/briarproject/bramble/rendezvous/RendezvousPollerImplTest.java
index e5b4a6463fd5d5aae223add01b7307b55270d7f1..27321d748e9f23470c0662c8ed8e8c213f37647d 100644
--- a/bramble-core/src/test/java/org/briarproject/bramble/rendezvous/RendezvousPollerImplTest.java
+++ b/bramble-core/src/test/java/org/briarproject/bramble/rendezvous/RendezvousPollerImplTest.java
@@ -1,8 +1,10 @@
 package org.briarproject.bramble.rendezvous;
 
 import org.briarproject.bramble.api.contact.PendingContact;
+import org.briarproject.bramble.api.contact.PendingContactState;
 import org.briarproject.bramble.api.contact.event.PendingContactAddedEvent;
 import org.briarproject.bramble.api.contact.event.PendingContactRemovedEvent;
+import org.briarproject.bramble.api.contact.event.PendingContactStateChangedEvent;
 import org.briarproject.bramble.api.crypto.KeyPair;
 import org.briarproject.bramble.api.crypto.SecretKey;
 import org.briarproject.bramble.api.crypto.TransportCrypto;
@@ -20,12 +22,15 @@ import org.briarproject.bramble.api.plugin.event.TransportEnabledEvent;
 import org.briarproject.bramble.api.properties.TransportProperties;
 import org.briarproject.bramble.api.rendezvous.KeyMaterialSource;
 import org.briarproject.bramble.api.rendezvous.RendezvousEndpoint;
-import org.briarproject.bramble.api.rendezvous.event.RendezvousFailedEvent;
+import org.briarproject.bramble.api.rendezvous.event.RendezvousConnectionClosedEvent;
+import org.briarproject.bramble.api.rendezvous.event.RendezvousConnectionOpenedEvent;
+import org.briarproject.bramble.api.rendezvous.event.RendezvousPollEvent;
 import org.briarproject.bramble.api.system.Clock;
 import org.briarproject.bramble.test.BrambleMockTestCase;
 import org.briarproject.bramble.test.CaptureArgumentAction;
 import org.briarproject.bramble.test.DbExpectations;
 import org.briarproject.bramble.test.ImmediateExecutor;
+import org.briarproject.bramble.test.PredicateMatcher;
 import org.jmock.Expectations;
 import org.junit.Before;
 import org.junit.Test;
@@ -38,6 +43,9 @@ import java.util.concurrent.atomic.AtomicReference;
 import static java.util.Collections.emptyList;
 import static java.util.Collections.singletonList;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static org.briarproject.bramble.api.contact.PendingContactState.ADDING_CONTACT;
+import static org.briarproject.bramble.api.contact.PendingContactState.FAILED;
+import static org.briarproject.bramble.api.contact.PendingContactState.WAITING_FOR_CONNECTION;
 import static org.briarproject.bramble.rendezvous.RendezvousConstants.POLLING_INTERVAL_MS;
 import static org.briarproject.bramble.rendezvous.RendezvousConstants.RENDEZVOUS_TIMEOUT_MS;
 import static org.briarproject.bramble.test.CollectionMatcher.collectionOf;
@@ -110,7 +118,10 @@ public class RendezvousPollerImplTest extends BrambleMockTestCase {
 			// The pending contact has not expired
 			oneOf(clock).currentTimeMillis();
 			will(returnValue(beforeExpiry));
-			// Capture the poll task, we'll run it later
+			oneOf(eventBus).broadcast(with(new PredicateMatcher<>(
+					PendingContactStateChangedEvent.class, e ->
+					e.getPendingContactState() == WAITING_FOR_CONNECTION)));
+			// Capture the poll task
 			oneOf(scheduler).scheduleAtFixedRate(with(any(Runnable.class)),
 					with(POLLING_INTERVAL_MS), with(POLLING_INTERVAL_MS),
 					with(MILLISECONDS));
@@ -124,11 +135,7 @@ public class RendezvousPollerImplTest extends BrambleMockTestCase {
 		context.assertIsSatisfied();
 
 		// Run the poll task - pending contact expires
-		context.checking(new Expectations() {{
-			oneOf(clock).currentTimeMillis();
-			will(returnValue(afterExpiry));
-			oneOf(eventBus).broadcast(with(any(RendezvousFailedEvent.class)));
-		}});
+		expectPendingContactExpires(afterExpiry);
 
 		capturePollTask.get().run();
 	}
@@ -147,7 +154,9 @@ public class RendezvousPollerImplTest extends BrambleMockTestCase {
 			// The pending contact has already expired
 			oneOf(clock).currentTimeMillis();
 			will(returnValue(atExpiry));
-			oneOf(eventBus).broadcast(with(any(RendezvousFailedEvent.class)));
+			oneOf(eventBus).broadcast(with(new PredicateMatcher<>(
+					PendingContactStateChangedEvent.class, e ->
+					e.getPendingContactState() == FAILED)));
 			// Schedule the poll task
 			oneOf(scheduler).scheduleAtFixedRate(with(any(Runnable.class)),
 					with(POLLING_INTERVAL_MS), with(POLLING_INTERVAL_MS),
@@ -175,11 +184,7 @@ public class RendezvousPollerImplTest extends BrambleMockTestCase {
 		context.assertIsSatisfied();
 
 		// Add the pending contact - endpoint should be created and polled
-		context.checking(new Expectations() {{
-			oneOf(clock).currentTimeMillis();
-			will(returnValue(beforeExpiry));
-		}});
-
+		expectAddUnexpiredPendingContact(beforeExpiry);
 		expectDeriveRendezvousKey();
 		expectCreateEndpoint();
 
@@ -187,6 +192,9 @@ public class RendezvousPollerImplTest extends BrambleMockTestCase {
 			// Poll newly added pending contact
 			oneOf(rendezvousEndpoint).getRemoteTransportProperties();
 			will(returnValue(transportProperties));
+			oneOf(clock).currentTimeMillis();
+			will(returnValue(beforeExpiry));
+			oneOf(eventBus).broadcast(with(any(RendezvousPollEvent.class)));
 			oneOf(plugin).poll(with(collectionOf(pairOf(
 					equal(transportProperties),
 					any(ConnectionHandler.class)))));
@@ -230,11 +238,7 @@ public class RendezvousPollerImplTest extends BrambleMockTestCase {
 		context.assertIsSatisfied();
 
 		// Add the pending contact - endpoint should be created and polled
-		context.checking(new Expectations() {{
-			oneOf(clock).currentTimeMillis();
-			will(returnValue(beforeExpiry));
-		}});
-
+		expectAddUnexpiredPendingContact(beforeExpiry);
 		expectDeriveRendezvousKey();
 		expectCreateEndpoint();
 
@@ -242,6 +246,9 @@ public class RendezvousPollerImplTest extends BrambleMockTestCase {
 			// Poll newly added pending contact
 			oneOf(rendezvousEndpoint).getRemoteTransportProperties();
 			will(returnValue(transportProperties));
+			oneOf(clock).currentTimeMillis();
+			will(returnValue(beforeExpiry));
+			oneOf(eventBus).broadcast(with(any(RendezvousPollEvent.class)));
 			oneOf(plugin).poll(with(collectionOf(pairOf(
 					equal(transportProperties),
 					any(ConnectionHandler.class)))));
@@ -252,11 +259,10 @@ public class RendezvousPollerImplTest extends BrambleMockTestCase {
 		context.assertIsSatisfied();
 
 		// Run the poll task - pending contact expires, endpoint is closed
+		expectPendingContactExpires(afterExpiry);
+
 		context.checking(new Expectations() {{
-			oneOf(clock).currentTimeMillis();
-			will(returnValue(afterExpiry));
 			oneOf(rendezvousEndpoint).close();
-			oneOf(eventBus).broadcast(with(any(RendezvousFailedEvent.class)));
 		}});
 
 		capturePollTask.get().run();
@@ -283,11 +289,7 @@ public class RendezvousPollerImplTest extends BrambleMockTestCase {
 		context.assertIsSatisfied();
 
 		// Add the pending contact - no endpoints should be created yet
-		context.checking(new DbExpectations() {{
-			oneOf(clock).currentTimeMillis();
-			will(returnValue(beforeExpiry));
-		}});
-
+		expectAddUnexpiredPendingContact(beforeExpiry);
 		expectDeriveRendezvousKey();
 
 		rendezvousPoller.eventOccurred(
@@ -314,15 +316,162 @@ public class RendezvousPollerImplTest extends BrambleMockTestCase {
 				new PendingContactRemovedEvent(pendingContact.getId()));
 	}
 
+	@Test
+	public void testRendezvousConnectionEvents() throws Exception {
+		long beforeExpiry = pendingContact.getTimestamp();
+
+		// Start the service
+		expectStartupWithPendingContact(beforeExpiry);
+
+		rendezvousPoller.startService();
+		context.assertIsSatisfied();
+
+		// Connection is opened - event should be broadcast
+		expectStateChangedEvent(ADDING_CONTACT);
+
+		rendezvousPoller.eventOccurred(
+				new RendezvousConnectionOpenedEvent(pendingContact.getId()));
+		context.assertIsSatisfied();
+
+		// Connection fails - event should be broadcast
+		expectStateChangedEvent(WAITING_FOR_CONNECTION);
+
+		rendezvousPoller.eventOccurred(new RendezvousConnectionClosedEvent(
+				pendingContact.getId(), false));
+	}
+
+	@Test
+	public void testPendingContactExpiresBeforeConnection() throws Exception {
+		long beforeExpiry = pendingContact.getTimestamp()
+				+ RENDEZVOUS_TIMEOUT_MS - 1000;
+		long afterExpiry = beforeExpiry + POLLING_INTERVAL_MS;
+
+		// Start the service, capturing the poll task
+		AtomicReference<Runnable> capturePollTask =
+				expectStartupWithPendingContact(beforeExpiry);
+
+		rendezvousPoller.startService();
+		context.assertIsSatisfied();
+
+		// Run the poll task - pending contact expires
+		expectPendingContactExpires(afterExpiry);
+
+		capturePollTask.get().run();
+		context.assertIsSatisfied();
+
+		// Connection is opened - no event should be broadcast
+		rendezvousPoller.eventOccurred(
+				new RendezvousConnectionOpenedEvent(pendingContact.getId()));
+		context.assertIsSatisfied();
+
+		// Connection fails - no event should be broadcast
+		rendezvousPoller.eventOccurred(new RendezvousConnectionClosedEvent(
+				pendingContact.getId(), false));
+	}
+
+	@Test
+	public void testPendingContactExpiresDuringFailedConnection()
+			throws Exception {
+		long beforeExpiry = pendingContact.getTimestamp()
+				+ RENDEZVOUS_TIMEOUT_MS - 1000;
+		long afterExpiry = beforeExpiry + POLLING_INTERVAL_MS;
+
+		// Start the service, capturing the poll task
+		AtomicReference<Runnable> capturePollTask =
+				expectStartupWithPendingContact(beforeExpiry);
+
+		rendezvousPoller.startService();
+		context.assertIsSatisfied();
+
+		// Connection is opened - event should be broadcast
+		expectStateChangedEvent(ADDING_CONTACT);
+
+		rendezvousPoller.eventOccurred(
+				new RendezvousConnectionOpenedEvent(pendingContact.getId()));
+		context.assertIsSatisfied();
+
+		// Run the poll task - pending contact expires
+		expectPendingContactExpires(afterExpiry);
+
+		capturePollTask.get().run();
+		context.assertIsSatisfied();
+
+		// Connection fails - no event should be broadcast
+		rendezvousPoller.eventOccurred(new RendezvousConnectionClosedEvent(
+				pendingContact.getId(), false));
+	}
+
+	@Test
+	public void testPendingContactExpiresDuringSuccessfulConnection()
+			throws Exception {
+		long beforeExpiry = pendingContact.getTimestamp()
+				+ RENDEZVOUS_TIMEOUT_MS - 1000;
+		long afterExpiry = beforeExpiry + POLLING_INTERVAL_MS;
+
+		// Start the service, capturing the poll task
+		AtomicReference<Runnable> capturePollTask =
+				expectStartupWithPendingContact(beforeExpiry);
+
+		rendezvousPoller.startService();
+		context.assertIsSatisfied();
+
+		// Connection is opened - event should be broadcast
+		expectStateChangedEvent(ADDING_CONTACT);
+
+		rendezvousPoller.eventOccurred(
+				new RendezvousConnectionOpenedEvent(pendingContact.getId()));
+		context.assertIsSatisfied();
+
+		// Run the poll task - pending contact expires
+		expectPendingContactExpires(afterExpiry);
+
+		capturePollTask.get().run();
+		context.assertIsSatisfied();
+
+		// Pending contact is removed - no event should be broadcast
+		rendezvousPoller.eventOccurred(
+				new PendingContactRemovedEvent(pendingContact.getId()));
+	}
+
+	@Test
+	public void testPendingContactRemovedDuringFailedConnection()
+			throws Exception {
+		long beforeExpiry = pendingContact.getTimestamp();
+
+		// Start the service
+		expectStartupWithPendingContact(beforeExpiry);
+
+		rendezvousPoller.startService();
+		context.assertIsSatisfied();
+
+		// Connection is opened - event should be broadcast
+		expectStateChangedEvent(ADDING_CONTACT);
+
+		rendezvousPoller.eventOccurred(
+				new RendezvousConnectionOpenedEvent(pendingContact.getId()));
+		context.assertIsSatisfied();
+
+		// Pending contact is removed - no event should be broadcast
+		rendezvousPoller.eventOccurred(
+				new PendingContactRemovedEvent(pendingContact.getId()));
+		context.assertIsSatisfied();
+
+		// Connection fails - no event should be broadcast
+		rendezvousPoller.eventOccurred(new RendezvousConnectionClosedEvent(
+				pendingContact.getId(), false));
+	}
+
 	private AtomicReference<Runnable> expectStartupWithNoPendingContacts()
 			throws Exception {
 		Transaction txn = new Transaction(null, true);
 		AtomicReference<Runnable> capturePollTask = new AtomicReference<>();
 
 		context.checking(new DbExpectations() {{
+			// Load the pending contacts
 			oneOf(db).transaction(with(true), withDbRunnable(txn));
 			oneOf(db).getPendingContacts(txn);
 			will(returnValue(emptyList()));
+			// Capture the poll task
 			oneOf(scheduler).scheduleAtFixedRate(with(any(Runnable.class)),
 					with(POLLING_INTERVAL_MS), with(POLLING_INTERVAL_MS),
 					with(MILLISECONDS));
@@ -333,6 +482,16 @@ public class RendezvousPollerImplTest extends BrambleMockTestCase {
 		return capturePollTask;
 	}
 
+	private void expectAddUnexpiredPendingContact(long now) {
+		context.checking(new Expectations() {{
+			oneOf(clock).currentTimeMillis();
+			will(returnValue(now));
+			oneOf(eventBus).broadcast(with(new PredicateMatcher<>(
+					PendingContactStateChangedEvent.class, e ->
+					e.getPendingContactState() == WAITING_FOR_CONNECTION)));
+		}});
+	}
+
 	private void expectDeriveRendezvousKey() throws Exception {
 		Transaction txn = new Transaction(null, true);
 
@@ -371,4 +530,50 @@ public class RendezvousPollerImplTest extends BrambleMockTestCase {
 			will(returnValue(transportId));
 		}});
 	}
+
+	private AtomicReference<Runnable> expectStartupWithPendingContact(long now)
+			throws Exception {
+		Transaction txn = new Transaction(null, true);
+		AtomicReference<Runnable> capturePollTask = new AtomicReference<>();
+
+		context.checking(new DbExpectations() {{
+			// Load the pending contacts
+			oneOf(db).transaction(with(true), withDbRunnable(txn));
+			oneOf(db).getPendingContacts(txn);
+			will(returnValue(singletonList(pendingContact)));
+			// The pending contact has not expired
+			oneOf(clock).currentTimeMillis();
+			will(returnValue(now));
+			oneOf(eventBus).broadcast(with(new PredicateMatcher<>(
+					PendingContactStateChangedEvent.class, e ->
+					e.getPendingContactState() == WAITING_FOR_CONNECTION)));
+			// Capture the poll task
+			oneOf(scheduler).scheduleAtFixedRate(with(any(Runnable.class)),
+					with(POLLING_INTERVAL_MS), with(POLLING_INTERVAL_MS),
+					with(MILLISECONDS));
+			will(new CaptureArgumentAction<>(capturePollTask, Runnable.class,
+					0));
+		}});
+
+		expectDeriveRendezvousKey();
+
+		return capturePollTask;
+	}
+
+	private void expectPendingContactExpires(long now) {
+		context.checking(new Expectations() {{
+			oneOf(clock).currentTimeMillis();
+			will(returnValue(now));
+		}});
+
+		expectStateChangedEvent(FAILED);
+	}
+
+	private void expectStateChangedEvent(PendingContactState state) {
+		context.checking(new Expectations() {{
+			oneOf(eventBus).broadcast(with(new PredicateMatcher<>(
+					PendingContactStateChangedEvent.class, e ->
+					e.getPendingContactState() == state)));
+		}});
+	}
 }
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactItem.java b/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactItem.java
index a56e94930d2952b6d50c11dee68a72cfd77e2e47..ec6d5a0f6f9bea49a4ea700bfface42c32490a0e 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactItem.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactItem.java
@@ -6,17 +6,24 @@ import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
 
 import javax.annotation.concurrent.Immutable;
 
+import static org.briarproject.bramble.api.contact.PendingContactState.CONNECTING;
+import static org.briarproject.bramble.api.contact.PendingContactState.WAITING_FOR_CONNECTION;
+
 @Immutable
 @NotNullByDefault
 class PendingContactItem {
 
+	static final int POLL_DURATION_MS = 15_000;
+
 	private final PendingContact pendingContact;
 	private final PendingContactState state;
+	private final long lastPoll;
 
 	PendingContactItem(PendingContact pendingContact,
-			PendingContactState state) {
+			PendingContactState state, long lastPoll) {
 		this.pendingContact = pendingContact;
 		this.state = state;
+		this.lastPoll = lastPoll;
 	}
 
 	PendingContact getPendingContact() {
@@ -24,6 +31,10 @@ class PendingContactItem {
 	}
 
 	PendingContactState getState() {
+		if (state == WAITING_FOR_CONNECTION &&
+				System.currentTimeMillis() - lastPoll < POLL_DURATION_MS) {
+			return CONNECTING;
+		}
 		return state;
 	}
 }
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactListActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactListActivity.java
index eff4cbff2d290ac1911ac9c7af007d642f08f098..c3decf51d3997e2f21e38061806aa7d9ee348478 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactListActivity.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactListActivity.java
@@ -23,6 +23,7 @@ import javax.annotation.Nullable;
 import javax.inject.Inject;
 
 import static org.briarproject.bramble.api.contact.PendingContactState.FAILED;
+import static org.briarproject.briar.android.contact.add.remote.PendingContactItem.POLL_DURATION_MS;
 
 @MethodsNotNullByDefault
 @ParametersNotNullByDefault
@@ -69,7 +70,7 @@ public class PendingContactListActivity extends BriarActivity
 	@Override
 	public void onStart() {
 		super.onStart();
-		list.startPeriodicUpdate();
+		list.startPeriodicUpdate(POLL_DURATION_MS);
 	}
 
 	@Override
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactListViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactListViewModel.java
index 675ec39c2d5b6611130edfa80826f73542f4eccd..5c9ea749171d1f4ffeecd7c0f3edad68f463619a 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactListViewModel.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactListViewModel.java
@@ -18,6 +18,8 @@ import org.briarproject.bramble.api.event.Event;
 import org.briarproject.bramble.api.event.EventBus;
 import org.briarproject.bramble.api.event.EventListener;
 import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.rendezvous.RendezvousPoller;
+import org.briarproject.bramble.api.rendezvous.event.RendezvousPollEvent;
 
 import java.util.ArrayList;
 import java.util.Collection;
@@ -41,6 +43,7 @@ public class PendingContactListViewModel extends AndroidViewModel
 	@DatabaseExecutor
 	private final Executor dbExecutor;
 	private final ContactManager contactManager;
+	private final RendezvousPoller rendezvousPoller;
 	private final EventBus eventBus;
 
 	private final MutableLiveData<Collection<PendingContactItem>>
@@ -49,10 +52,13 @@ public class PendingContactListViewModel extends AndroidViewModel
 	@Inject
 	PendingContactListViewModel(Application application,
 			@DatabaseExecutor Executor dbExecutor,
-			ContactManager contactManager, EventBus eventBus) {
+			ContactManager contactManager,
+			RendezvousPoller rendezvousPoller,
+			EventBus eventBus) {
 		super(application);
 		this.dbExecutor = dbExecutor;
 		this.contactManager = contactManager;
+		this.rendezvousPoller = rendezvousPoller;
 		this.eventBus = eventBus;
 		this.eventBus.addListener(this);
 		loadPendingContacts();
@@ -67,7 +73,8 @@ public class PendingContactListViewModel extends AndroidViewModel
 	@Override
 	public void eventOccurred(Event e) {
 		if (e instanceof PendingContactStateChangedEvent ||
-				e instanceof PendingContactRemovedEvent) {
+				e instanceof PendingContactRemovedEvent ||
+				e instanceof RendezvousPollEvent) {
 			loadPendingContacts();
 		}
 	}
@@ -78,9 +85,11 @@ public class PendingContactListViewModel extends AndroidViewModel
 				Collection<Pair<PendingContact, PendingContactState>> pairs =
 						contactManager.getPendingContacts();
 				List<PendingContactItem> items = new ArrayList<>(pairs.size());
-				for (Pair<PendingContact, PendingContactState> p : pairs) {
-					items.add(new PendingContactItem(p.getFirst(),
-							p.getSecond()));
+				for (Pair<PendingContact, PendingContactState> pair : pairs) {
+					PendingContact p = pair.getFirst();
+					long lastPoll = rendezvousPoller.getLastPollTime(p.getId());
+					items.add(new PendingContactItem(p, pair.getSecond(),
+							lastPoll));
 				}
 				pendingContacts.postValue(items);
 			} catch (DbException e) {
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactViewHolder.java b/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactViewHolder.java
index d66fae80e345e366d331e2fdeb19189159c9e579..980ef3bec013dfbb340954b169f617ce89e9c4a4 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactViewHolder.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactViewHolder.java
@@ -52,7 +52,7 @@ class PendingContactViewHolder extends ViewHolder {
 						.getColor(status.getContext(), R.color.briar_yellow);
 				status.setText(R.string.waiting_for_contact_to_come_online);
 				break;
-			case CONNECTED:
+			case CONNECTING:
 				status.setText(R.string.connecting);
 				break;
 			case ADDING_CONTACT:
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/view/BriarRecyclerView.java b/briar-android/src/main/java/org/briarproject/briar/android/view/BriarRecyclerView.java
index 98448c9437743ff3bcd6dc21b63dae80a00b4f0e..9c203a0e8284c9166fadd32ffc3b7345f01cc6c3 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/view/BriarRecyclerView.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/view/BriarRecyclerView.java
@@ -211,15 +211,19 @@ public class BriarRecyclerView extends FrameLayout {
 	}
 
 	public void startPeriodicUpdate() {
+		startPeriodicUpdate(MIN_DATE_RESOLUTION);
+	}
+
+	public void startPeriodicUpdate(long interval) {
 		if (recyclerView == null || recyclerView.getAdapter() == null) {
 			throw new IllegalStateException("Need to call setAdapter() first!");
 		}
 		refresher = () -> {
 			Adapter adapter = recyclerView.getAdapter();
 			adapter.notifyItemRangeChanged(0, adapter.getItemCount());
-			handler.postDelayed(refresher, MIN_DATE_RESOLUTION);
+			handler.postDelayed(refresher, interval);
 		};
-		handler.postDelayed(refresher, MIN_DATE_RESOLUTION);
+		handler.postDelayed(refresher, interval);
 	}
 
 	public void stopPeriodicUpdate() {
diff --git a/briar-android/src/main/res/values/strings.xml b/briar-android/src/main/res/values/strings.xml
index f48a8710910ccdd0469c2bed7e9891bf3db6727c..a22872df78a6e9e893c4e11c64b8986428aa8bc6 100644
--- a/briar-android/src/main/res/values/strings.xml
+++ b/briar-android/src/main/res/values/strings.xml
@@ -193,7 +193,7 @@
 	<string name="pending_contact_requests">Pending Contact Requests</string>
 	<string name="no_pending_contacts">No pending contacts</string>
 	<string name="add_contact_remote_connecting">Connecting…</string>
-	<string name="waiting_for_contact_to_come_online">Waiting for contact to come online…\n\nDid they enter your link already?</string>
+	<string name="waiting_for_contact_to_come_online">Waiting for contact to come online…</string>
 	<string name="connecting">Connecting…</string>
 	<string name="adding_contact">Adding contact…</string>
 	<string name="adding_contact_failed">Adding contact has failed</string>
diff --git a/briar-headless/README.md b/briar-headless/README.md
index 0804e6d26c0bf49381820bfdac47954506ecb2d7..19004e25accecfcb361579b1008b6060437ffff6 100644
--- a/briar-headless/README.md
+++ b/briar-headless/README.md
@@ -131,7 +131,7 @@ This will return a JSON array of pending contacts and their states:
 The state can be one of these values:
 
   * `waiting_for_connection`
-  * `connected`
+  * `connecting`
   * `adding_contact`
   * `failed`
 
diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/contact/OutputPendingContact.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/contact/OutputPendingContact.kt
index 853e0eb1fba925981d6015dbba0d1cf79549a507..b6226b627511d9aee80825280df6642ecec767a3 100644
--- a/briar-headless/src/main/java/org/briarproject/briar/headless/contact/OutputPendingContact.kt
+++ b/briar-headless/src/main/java/org/briarproject/briar/headless/contact/OutputPendingContact.kt
@@ -16,7 +16,7 @@ internal fun PendingContact.output() = JsonDict(
 
 internal fun PendingContactState.output() = when(this) {
     WAITING_FOR_CONNECTION -> "waiting_for_connection"
-    CONNECTED -> "connected"
+    CONNECTING -> "connecting"
     ADDING_CONTACT -> "adding_contact"
     FAILED -> "failed"
     else -> throw AssertionError()