diff --git a/app/src/main/java/org/briarproject/publicmesh/util/IoUtils.java b/app/src/main/java/org/briarproject/publicmesh/util/IoUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..37c0619b5f8dbe821ddc22ada28a16e0362aedf5
--- /dev/null
+++ b/app/src/main/java/org/briarproject/publicmesh/util/IoUtils.java
@@ -0,0 +1,66 @@
+package org.briarproject.publicmesh.util;
+
+import org.briarproject.publicmesh.nullsafety.NotNullByDefault;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import javax.annotation.Nullable;
+
+import static org.briarproject.publicmesh.util.LogUtils.logException;
+
+@NotNullByDefault
+public class IoUtils {
+
+	public static void tryToClose(@Nullable Socket s, Logger logger,
+			Level level) {
+		try {
+			if (s != null) s.close();
+		} catch (IOException e) {
+			logException(logger, level, e);
+		}
+	}
+
+	public static void tryToClose(@Nullable ServerSocket ss, Logger logger,
+			Level level) {
+		try {
+			if (ss != null) ss.close();
+		} catch (IOException e) {
+			logException(logger, level, e);
+		}
+	}
+
+	// Workaround for a bug in Android 7, see
+	// https://android-review.googlesource.com/#/c/271775/
+	public static InputStream getInputStream(Socket s) throws IOException {
+		try {
+			return s.getInputStream();
+		} catch (NullPointerException e) {
+			throw new IOException(e);
+		}
+	}
+
+	// Workaround for a bug in Android 7, see
+	// https://android-review.googlesource.com/#/c/271775/
+	public static OutputStream getOutputStream(Socket s) throws IOException {
+		try {
+			return s.getOutputStream();
+		} catch (NullPointerException e) {
+			throw new IOException(e);
+		}
+	}
+
+	public static byte[] ipAddressIntToBytes(int ip) {
+		byte[] ipBytes = new byte[4];
+		ipBytes[0] = (byte) (ip & 0xFF);
+		ipBytes[1] = (byte) ((ip >> 8) & 0xFF);
+		ipBytes[2] = (byte) ((ip >> 16) & 0xFF);
+		ipBytes[3] = (byte) ((ip >> 24) & 0xFF);
+		return ipBytes;
+	}
+}
diff --git a/app/src/main/java/org/briarproject/publicmesh/wifidirect/WifiDirectServiceImpl.java b/app/src/main/java/org/briarproject/publicmesh/wifidirect/WifiDirectServiceImpl.java
index 59b510128d139b019b11a811395850dd5bdb18bc..bd3ea5b1bee35f172f9e01eba32e04a8d7a23248 100644
--- a/app/src/main/java/org/briarproject/publicmesh/wifidirect/WifiDirectServiceImpl.java
+++ b/app/src/main/java/org/briarproject/publicmesh/wifidirect/WifiDirectServiceImpl.java
@@ -6,7 +6,12 @@ import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
+import android.net.ConnectivityManager;
+import android.net.ConnectivityManager.NetworkCallback;
+import android.net.DhcpInfo;
 import android.net.MacAddress;
+import android.net.Network;
+import android.net.NetworkRequest;
 import android.net.wifi.WifiConfiguration;
 import android.net.wifi.WifiInfo;
 import android.net.wifi.WifiManager;
@@ -16,6 +21,7 @@ import android.net.wifi.p2p.WifiP2pDevice;
 import android.net.wifi.p2p.WifiP2pManager;
 import android.net.wifi.p2p.WifiP2pManager.ActionListener;
 import android.net.wifi.p2p.WifiP2pManager.Channel;
+import android.net.wifi.p2p.WifiP2pManager.ConnectionInfoListener;
 import android.net.wifi.p2p.WifiP2pManager.DnsSdServiceResponseListener;
 import android.net.wifi.p2p.WifiP2pManager.DnsSdTxtRecordListener;
 import android.net.wifi.p2p.WifiP2pManager.GroupInfoListener;
@@ -27,18 +33,30 @@ import org.briarproject.publicmesh.R;
 import org.briarproject.publicmesh.lifecycle.Service;
 import org.briarproject.publicmesh.nullsafety.NotNullByDefault;
 
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.UnknownHostException;
 import java.util.Map;
+import java.util.Random;
 import java.util.TreeMap;
 import java.util.logging.Logger;
 
 import javax.inject.Inject;
+import javax.net.SocketFactory;
 
 import androidx.annotation.Nullable;
 import androidx.lifecycle.LiveData;
 import androidx.lifecycle.MutableLiveData;
 
+import static android.content.Context.CONNECTIVITY_SERVICE;
 import static android.content.Context.WIFI_P2P_SERVICE;
 import static android.content.Context.WIFI_SERVICE;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
+import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
 import static android.net.wifi.WifiManager.WIFI_MODE_FULL;
 import static android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF;
 import static android.net.wifi.p2p.WifiP2pManager.BUSY;
@@ -48,7 +66,12 @@ import static android.net.wifi.p2p.WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION;
 import static android.net.wifi.p2p.WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION;
 import static android.os.Build.VERSION.SDK_INT;
 import static java.util.Objects.requireNonNull;
+import static java.util.logging.Level.INFO;
 import static java.util.logging.Logger.getLogger;
+import static org.briarproject.publicmesh.util.IoUtils.getInputStream;
+import static org.briarproject.publicmesh.util.IoUtils.getOutputStream;
+import static org.briarproject.publicmesh.util.IoUtils.ipAddressIntToBytes;
+import static org.briarproject.publicmesh.util.IoUtils.tryToClose;
 import static org.briarproject.publicmesh.util.StringUtils.getRandomString;
 import static org.briarproject.publicmesh.wifidirect.WifiDirectService.Status.STARTED;
 import static org.briarproject.publicmesh.wifidirect.WifiDirectService.Status.STARTING;
@@ -61,9 +84,12 @@ class WifiDirectServiceImpl implements WifiDirectService, Service {
 	private static final String SERVICE_TYPE = "_mesh._tcp";
 	private static final String KEY_NETWORK_NAME = "ssid";
 	private static final String KEY_PASSPHRASE = "pass";
+	private static final int PORT = 55555;
 
 	private static final int MAX_GROUP_INFO_ATTEMPTS = 5;
+	private static final int MAX_CONNECTION_INFO_ATTEMPTS = 5;
 	private static final int MAX_WIFI_CONNECTION_ATTEMPTS = 5;
+	private static final int MAX_SOCKET_CONNECTION_ATTEMPTS = 5;
 
 	private static final Logger LOG = getLogger(WifiDirectServiceImpl.class.getName());
 
@@ -74,6 +100,7 @@ class WifiDirectServiceImpl implements WifiDirectService, Service {
 	private final WifiManager wifiManager;
 	@Nullable
 	private final WifiP2pManager wifiP2pManager;
+	private final ConnectivityManager connectivityManager;
 	private final MutableLiveData<Status> hotspotStatus = new MutableLiveData<>(STOPPED);
 	private final MutableLiveData<Status> discoveryStatus = new MutableLiveData<>(STOPPED);
 	private final WifiDirectBroadcastReceiver receiver = new WifiDirectBroadcastReceiver();
@@ -83,12 +110,19 @@ class WifiDirectServiceImpl implements WifiDirectService, Service {
 	private WifiLock wifiLock;
 	@Nullable
 	private Channel channel;
-	private boolean connectionInProgress = false;
+	private boolean connectionInProgress = false, connectedToPeer = false;
 	@Nullable
 	private WifiP2pDnsSdServiceInfo serviceInfo;
 	@Nullable
 	private WifiP2pDnsSdServiceRequest serviceRequest;
 	private int networkId = -1;
+	@Nullable
+	private ServerSocket serverSocket;
+	@Nullable
+	private NetworkCallback networkCallback;
+	private SocketFactory socketFactory = SocketFactory.getDefault();
+	@Nullable
+	private SenderThread senderThread;
 
 	@Inject
 	WifiDirectServiceImpl(Application app) {
@@ -97,6 +131,8 @@ class WifiDirectServiceImpl implements WifiDirectService, Service {
 		handler = new Handler(app.getMainLooper());
 		wifiManager = (WifiManager) app.getSystemService(WIFI_SERVICE);
 		wifiP2pManager = (WifiP2pManager) app.getSystemService(WIFI_P2P_SERVICE);
+		connectivityManager = (ConnectivityManager)
+				requireNonNull(app.getSystemService(CONNECTIVITY_SERVICE));
 	}
 
 	@Override
@@ -216,6 +252,15 @@ class WifiDirectServiceImpl implements WifiDirectService, Service {
 	private void addService(String networkName, String passphrase) {
 		Channel channel = this.channel;
 		if (channel == null) return;
+		try {
+			serverSocket = new ServerSocket(PORT);
+			LOG.info("Opened server socket " + serverSocket.getLocalSocketAddress());
+			new ServerThread(serverSocket).start();
+		} catch (IOException e) {
+			LOG.info("Failed to open server socket: " + e);
+			releaseHotspot();
+			return;
+		}
 		Map<String, String> record = new TreeMap<>();
 		record.put(KEY_NETWORK_NAME, networkName);
 		record.put(KEY_PASSPHRASE, passphrase);
@@ -242,6 +287,7 @@ class WifiDirectServiceImpl implements WifiDirectService, Service {
 	}
 
 	private void releaseHotspot() {
+		closeServerSocket();
 		removeService();
 		if (SDK_INT >= 27) requireNonNull(channel).close();
 		channel = null;
@@ -250,6 +296,12 @@ class WifiDirectServiceImpl implements WifiDirectService, Service {
 		LOG.info("Released hotspot");
 	}
 
+	private void closeServerSocket() {
+		if (serverSocket == null) return;
+		tryToClose(serverSocket, LOG, INFO);
+		serverSocket = null;
+	}
+
 	private void removeService() {
 		if (serviceInfo == null) return;
 		ActionListener listener = new ActionListener() {
@@ -422,7 +474,23 @@ class WifiDirectServiceImpl implements WifiDirectService, Service {
 	}
 
 	private void connectToPeer(WifiP2pDevice device, String networkName, String passphrase) {
-		if (channel == null || connectionInProgress) return;
+		Channel channel = this.channel;
+		if (channel == null || connectionInProgress || connectedToPeer) return;
+		// If we're using wifi (not WFD) on API 21+ we need the network's socket factory
+		if (SDK_INT >= 21 && SDK_INT < 29) {
+			NetworkRequest networkRequest = new NetworkRequest.Builder()
+					.addTransportType(TRANSPORT_WIFI)
+					.removeCapability(NET_CAPABILITY_INTERNET)
+					.build();
+			networkCallback = new NetworkCallback() {
+				@Override
+				public void onAvailable(Network network) {
+					LOG.info("Network is available, setting socket factory");
+					socketFactory = network.getSocketFactory();
+				}
+			};
+			connectivityManager.registerNetworkCallback(networkRequest, networkCallback);
+		}
 		if (SDK_INT >= 29) {
 			LOG.info("Connecting to peer " + device.deviceName);
 			connectionInProgress = true;
@@ -435,11 +503,15 @@ class WifiDirectServiceImpl implements WifiDirectService, Service {
 				@Override
 				public void onSuccess() {
 					LOG.info("Connected to peer " + device.deviceName);
+					connectedToPeer = true;
+					connectionInProgress = false;
+					requestConnectionInfo(1);
 				}
 
 				@Override
 				public void onFailure(int reason) {
 					LOG.info("Failed to connect to peer " + device.deviceName + ": " + reason);
+					connectionInProgress = false;
 				}
 			};
 			try {
@@ -465,6 +537,33 @@ class WifiDirectServiceImpl implements WifiDirectService, Service {
 		}
 	}
 
+	private void requestConnectionInfo(int attempt) {
+		Channel channel = this.channel;
+		if (channel == null) return;
+		ConnectionInfoListener listener = info -> {
+			if (info.groupOwnerAddress == null) {
+				// On some devices we need to wait for the connection info to become available
+				if (attempt < MAX_CONNECTION_INFO_ATTEMPTS) {
+					handler.postDelayed(() -> requestConnectionInfo(attempt + 1), 1000);
+				} else {
+					LOG.info("Failed to get group owner's address");
+					releaseDiscovery();
+				}
+			} else {
+				LOG.info("Server address " + info.groupOwnerAddress);
+				senderThread = new SenderThread(info.groupOwnerAddress);
+				senderThread.start();
+			}
+		};
+		try {
+			requireNonNull(wifiP2pManager).requestConnectionInfo(channel, listener);
+			LOG.info("Requested connection info");
+		} catch (SecurityException e) {
+			LOG.info("Failed to request connection info: " + e);
+			releaseDiscovery();
+		}
+	}
+
 	private void connectToNetwork(String networkName, int networkId, int attempt) {
 		String ssid = "\"" + networkName + "\"";
 		try {
@@ -476,8 +575,23 @@ class WifiDirectServiceImpl implements WifiDirectService, Service {
 				// If we want to allow the device to remain connected to another network,
 				// we should return here and there's no need to call disconnect() below
 			} else {
-				LOG.info("Successfully connected to wifi network " + networkName);
-				return;
+				DhcpInfo dhcpInfo = wifiManager.getDhcpInfo();
+				if (dhcpInfo == null) {
+					LOG.info("No DHCP info");
+					// Fall through
+				} else {
+					LOG.info("Successfully connected to wifi network " + networkName);
+					byte[] ipBytes = ipAddressIntToBytes(dhcpInfo.gateway);
+					try {
+						InetAddress serverAddress = InetAddress.getByAddress(ipBytes);
+						LOG.info("Server address " + serverAddress);
+						senderThread = new SenderThread(serverAddress);
+						senderThread.start();
+						return;
+					} catch (UnknownHostException e) {
+						throw new AssertionError(e);
+					}
+				}
 			}
 			if (attempt > MAX_WIFI_CONNECTION_ATTEMPTS) return;
 			LOG.info("Connecting to " + networkName + ", attempt " + attempt);
@@ -496,21 +610,21 @@ class WifiDirectServiceImpl implements WifiDirectService, Service {
 	}
 
 	private void releaseDiscovery() {
-		if (connectionInProgress) {
-			cancelConnection();
-			removeGroup();
-			connectionInProgress = false;
-		}
+		cancelConnection();
+		removeGroup();
 		removeServiceRequest();
 		if (SDK_INT >= 27) requireNonNull(channel).close();
 		channel = null;
 		removeNetwork();
+		unregisterNetworkCallback();
+		interruptSenderThread();
 		releaseLock();
 		discoveryStatus.postValue(STOPPED);
 		LOG.info("Released service discovery");
 	}
 
 	private void cancelConnection() {
+		if (!connectionInProgress) return;
 		ActionListener listener = new ActionListener() {
 			@Override
 			public void onSuccess() {
@@ -527,9 +641,11 @@ class WifiDirectServiceImpl implements WifiDirectService, Service {
 		} catch (SecurityException e) {
 			LOG.info("Failed to cancel connection: " + e);
 		}
+		connectionInProgress = false;
 	}
 
 	private void removeGroup() {
+		if (!connectedToPeer) return;
 		ActionListener listener = new ActionListener() {
 			@Override
 			public void onSuccess() {
@@ -546,6 +662,7 @@ class WifiDirectServiceImpl implements WifiDirectService, Service {
 		} catch (SecurityException e) {
 			LOG.info("Failed to remove group: " + e);
 		}
+		connectedToPeer = false;
 	}
 
 	private void removeServiceRequest() {
@@ -581,6 +698,20 @@ class WifiDirectServiceImpl implements WifiDirectService, Service {
 		networkId = -1;
 	}
 
+	private void unregisterNetworkCallback() {
+		if (SDK_INT < 21 || networkCallback == null) return;
+		requireNonNull(connectivityManager).unregisterNetworkCallback(networkCallback);
+		LOG.info("Unregistered network callback");
+		networkCallback = null;
+	}
+
+	private void interruptSenderThread() {
+		if (senderThread == null) return;
+		senderThread.interrupt();
+		LOG.info("Interrupted sender thread");
+		senderThread = null;
+	}
+
 	@Override
 	public void start() {
 		IntentFilter filter = new IntentFilter();
@@ -607,4 +738,114 @@ class WifiDirectServiceImpl implements WifiDirectService, Service {
 			if (WIFI_P2P_PEERS_CHANGED_ACTION.equals(action)) requestPeers();
 		}
 	}
+
+	private static class ServerThread extends Thread {
+
+		private final ServerSocket serverSocket;
+
+		private ServerThread(ServerSocket serverSocket) {
+			this.serverSocket = serverSocket;
+		}
+
+		@Override
+		public void run() {
+			try {
+				// We'll break out of the loop when the server socket is closed
+				//noinspection InfiniteLoopStatement
+				while (true) {
+					Socket s = serverSocket.accept();
+					LOG.info("Incoming connection from " + s.getRemoteSocketAddress());
+					new ReceiverThread(s).start();
+				}
+			} catch (IOException e) {
+				LOG.info("Server thread exiting: " + e);
+			}
+		}
+	}
+
+	private static class ReceiverThread extends Thread {
+
+		private final Socket socket;
+
+		private ReceiverThread(Socket socket) {
+			this.socket = socket;
+		}
+
+		@Override
+		public void run() {
+			try {
+				InputStream in = getInputStream(socket);
+				OutputStream out = getOutputStream(socket);
+				byte[] buf = new byte[4096];
+				while (true) {
+					int read = in.read(buf);
+					if (read == -1) {
+						LOG.info("Receiver thread exiting: EOF");
+						return;
+					}
+					LOG.info("Received " + read + " bytes");
+					out.write(buf, 0, read);
+					LOG.info("Sent " + read + " bytes");
+				}
+			} catch (IOException e) {
+				LOG.info("Receiver thread exiting: " + e);
+				tryToClose(socket, LOG, INFO);
+			}
+		}
+	}
+
+	private class SenderThread extends Thread {
+
+		private final InetAddress serverAddress;
+
+		private SenderThread(InetAddress serverAddress) {
+			this.serverAddress = serverAddress;
+		}
+
+		@Override
+		public void run() {
+			Socket socket = null;
+			for (int i = 0; i < MAX_SOCKET_CONNECTION_ATTEMPTS; i++) {
+				try {
+					LOG.info("Connecting to " + serverAddress);
+					socket = socketFactory.createSocket(serverAddress, PORT);
+					LOG.info("Connected to " + serverAddress);
+					break;
+				} catch (IOException e) {
+					LOG.info("Failed to connect to " + serverAddress + ": " + e);
+				}
+				try {
+					Thread.sleep(1000);
+				} catch (InterruptedException e) {
+					LOG.info("Sender thread interrupted");
+					return;
+				}
+			}
+			if (socket == null) return;
+			byte[] buf = new byte[4096];
+			Random random = new Random();
+			try {
+				InputStream in = getInputStream(socket);
+				OutputStream out = getOutputStream(socket);
+				// We'll break out of the loop when the sender thread is interrupted
+				while (true) {
+					int bytesToSend =  1 + random.nextInt(99);
+					out.write(buf, 0, bytesToSend);
+					LOG.info("Sent " + bytesToSend + " bytes");
+					int read = in.read(buf);
+					if (read == -1) {
+						LOG.info("Sender thread exiting: EOF");
+						tryToClose(socket, LOG, INFO);
+						return;
+					}
+					LOG.info("Received " + read + " bytes");
+					//noinspection BusyWait
+					Thread.sleep(1000);
+				}
+			} catch (IOException | InterruptedException e) {
+				LOG.info("Sender thread exiting: " + e);
+				tryToClose(socket, LOG, INFO);
+			}
+		}
+	}
 }