diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/core/AndroidEagerSingletons.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/core/AndroidEagerSingletons.kt
index 513cba76b99d3adc2a7de2710dfa4ff1319ca655..97d69d839a4641b25014bab144ea6d7256640b3d 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/core/AndroidEagerSingletons.kt
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/AndroidEagerSingletons.kt
@@ -29,6 +29,6 @@ import javax.inject.Inject
 internal class AndroidEagerSingletons @Inject constructor(
     val androidTaskScheduler: AndroidTaskScheduler,
     val androidNetworkManager: AndroidNetworkManager,
-    val androidTorPlugin: TorPlugin,
+    val torPlugin: TorPlugin,
     val dozeWatchdog: DozeWatchdog,
 )
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidTorPlugin.java b/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidTorPlugin.java
index 58a2077575ebc1d1dbd9b11b9b934c43af41219a..defa904f1122385062693b50fa0804bee2adfc01 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidTorPlugin.java
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidTorPlugin.java
@@ -51,7 +51,7 @@ import static java.util.Arrays.asList;
 import static org.briarproject.mailbox.core.util.LogUtils.info;
 import static org.slf4j.LoggerFactory.getLogger;
 
-public class AndroidTorPlugin extends TorPlugin {
+public class AndroidTorPlugin extends AbstractTorPlugin {
 
 	private static final List<String> LIBRARY_ARCHITECTURES =
 			asList("armeabi-v7a", "arm64-v8a", "x86", "x86_64");
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/AbstractTorPlugin.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/AbstractTorPlugin.java
new file mode 100644
index 0000000000000000000000000000000000000000..27b946ab2d0b1151929dfb0434e9aaf7edd0a024
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/AbstractTorPlugin.java
@@ -0,0 +1,854 @@
+/*
+ *     Briar Mailbox
+ *     Copyright (C) 2021-2022  The Briar Project
+ *
+ *     This program is free software: you can redistribute it and/or modify
+ *     it under the terms of the GNU Affero General Public License as
+ *     published by the Free Software Foundation, either version 3 of the
+ *     License, or (at your option) any later version.
+ *
+ *     This program is distributed in the hope that it will be useful,
+ *     but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *     GNU Affero General Public License for more details.
+ *
+ *     You should have received a copy of the GNU Affero General Public License
+ *     along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ */
+
+package org.briarproject.mailbox.core.tor;
+
+import net.freehaven.tor.control.EventHandler;
+import net.freehaven.tor.control.TorControlConnection;
+import net.freehaven.tor.control.TorNotRunningException;
+
+import org.briarproject.mailbox.core.PoliteExecutor;
+import org.briarproject.mailbox.core.db.DbException;
+import org.briarproject.mailbox.core.event.Event;
+import org.briarproject.mailbox.core.event.EventListener;
+import org.briarproject.mailbox.core.lifecycle.IoExecutor;
+import org.briarproject.mailbox.core.lifecycle.ServiceException;
+import org.briarproject.mailbox.core.server.WebServerManager;
+import org.briarproject.mailbox.core.settings.Settings;
+import org.briarproject.mailbox.core.settings.SettingsManager;
+import org.briarproject.mailbox.core.system.Clock;
+import org.briarproject.mailbox.core.system.LocationUtils;
+import org.briarproject.mailbox.core.system.ResourceProvider;
+import org.briarproject.mailbox.core.tor.CircumventionProvider.BridgeType;
+import org.slf4j.Logger;
+
+import java.io.ByteArrayInputStream;
+import java.io.EOFException;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.Socket;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.zip.ZipInputStream;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+import javax.annotation.concurrent.ThreadSafe;
+
+import kotlinx.coroutines.flow.MutableStateFlow;
+import kotlinx.coroutines.flow.StateFlow;
+
+import static java.util.Arrays.asList;
+import static java.util.Collections.emptyList;
+import static java.util.Collections.singletonList;
+import static java.util.Collections.singletonMap;
+import static kotlinx.coroutines.flow.StateFlowKt.MutableStateFlow;
+import static net.freehaven.tor.control.TorControlCommands.HS_ADDRESS;
+import static net.freehaven.tor.control.TorControlCommands.HS_PRIVKEY;
+import static org.briarproject.mailbox.core.tor.CircumventionProvider.BridgeType.MEEK;
+import static org.briarproject.mailbox.core.tor.TorConstants.CONTROL_PORT;
+import static org.briarproject.mailbox.core.tor.TorConstants.HS_ADDRESS_V3;
+import static org.briarproject.mailbox.core.tor.TorConstants.HS_PRIVATE_KEY_V3;
+import static org.briarproject.mailbox.core.tor.TorConstants.SETTINGS_NAMESPACE;
+import static org.briarproject.mailbox.core.util.IoUtils.copyAndClose;
+import static org.briarproject.mailbox.core.util.IoUtils.tryToClose;
+import static org.briarproject.mailbox.core.util.LogUtils.info;
+import static org.briarproject.mailbox.core.util.LogUtils.logException;
+import static org.briarproject.mailbox.core.util.LogUtils.warn;
+import static org.briarproject.mailbox.core.util.PrivacyUtils.scrubOnion;
+import static org.slf4j.LoggerFactory.getLogger;
+
+public abstract class AbstractTorPlugin
+		implements TorPlugin, EventHandler, EventListener {
+
+	private static final Logger LOG = getLogger(AbstractTorPlugin.class);
+
+	private static final String[] EVENTS = {
+			"CIRC",
+			"ORCONN",
+			"STATUS_GENERAL",
+			"STATUS_CLIENT",
+			"HS_DESC",
+			"NOTICE",
+			"WARN",
+			"ERR"
+	};
+	private static final String OWNER = "__OwningControllerProcess";
+	private static final int COOKIE_TIMEOUT_MS = 3000;
+	private static final int COOKIE_POLLING_INTERVAL_MS = 200;
+	/**
+	 * The number of uploads of our onion service descriptor we wait for
+	 * before we consider our onion service to be published.
+	 * In reality, the actual reachability is more complicated,
+	 * but this might be a reasonable heuristic.
+	 */
+	private static final int HS_DESC_UPLOADS = 1;
+	private final Pattern bootstrapPattern =
+			Pattern.compile("^Bootstrapped ([0-9]{1,3})%.*$");
+	private final Pattern clockSkewPattern = Pattern.compile("CLOCK_SKEW");
+
+	private final Executor ioExecutor;
+	private final Executor connectionStatusExecutor;
+	private final SettingsManager settingsManager;
+	private final NetworkManager networkManager;
+	private final LocationUtils locationUtils;
+	private final Clock clock;
+	@Nullable
+	private final String architecture;
+	private final CircumventionProvider circumventionProvider;
+	private final ResourceProvider resourceProvider;
+	private final File torDirectory, configFile;
+	private final File doneFile, cookieFile;
+	private final AtomicBoolean used = new AtomicBoolean(false);
+
+	protected final PluginState state = new PluginState();
+
+	private volatile Socket controlSocket = null;
+	private volatile TorControlConnection controlConnection = null;
+
+	protected abstract int getProcessId();
+
+	protected abstract long getLastUpdateTime();
+
+	AbstractTorPlugin(Executor ioExecutor,
+			SettingsManager settingsManager,
+			NetworkManager networkManager,
+			LocationUtils locationUtils,
+			Clock clock,
+			ResourceProvider resourceProvider,
+			CircumventionProvider circumventionProvider,
+			@Nullable String architecture,
+			File torDirectory) {
+		this.ioExecutor = ioExecutor;
+		this.settingsManager = settingsManager;
+		this.networkManager = networkManager;
+		this.locationUtils = locationUtils;
+		this.clock = clock;
+		this.resourceProvider = resourceProvider;
+		this.circumventionProvider = circumventionProvider;
+		this.architecture = architecture;
+		this.torDirectory = torDirectory;
+		configFile = new File(torDirectory, "torrc");
+		doneFile = new File(torDirectory, "done");
+		cookieFile = new File(torDirectory, ".tor/control_auth_cookie");
+		// Don't execute more than one connection status check at a time
+		connectionStatusExecutor =
+				new PoliteExecutor("TorPlugin", ioExecutor, 1);
+	}
+
+	protected File getTorExecutableFile() {
+		return new File(torDirectory, "tor");
+	}
+
+	protected File getObfs4ExecutableFile() {
+		return new File(torDirectory, "obfs4proxy");
+	}
+
+	public StateFlow<TorState> getState() {
+		return state.state;
+	}
+
+	@Override
+	public void startService() throws ServiceException {
+		if (used.getAndSet(true)) throw new IllegalStateException();
+		if (!torDirectory.exists()) {
+			if (!torDirectory.mkdirs()) {
+				LOG.warn("Could not create Tor directory.");
+				throw new ServiceException();
+			}
+		}
+		try {
+			// Install or update the assets if necessary
+			if (!assetsAreUpToDate()) installAssets();
+			// Start from the default config every time
+			extract(getConfigInputStream(), configFile);
+		} catch (IOException e) {
+			throw new ServiceException(e);
+		}
+		if (cookieFile.exists() && !cookieFile.delete())
+			LOG.warn("Old auth cookie not deleted");
+		// Start a new Tor process
+		LOG.info("Starting Tor");
+		File torFile = getTorExecutableFile();
+		String torPath = torFile.getAbsolutePath();
+		String configPath = configFile.getAbsolutePath();
+		String pid = String.valueOf(getProcessId());
+		Process torProcess;
+		ProcessBuilder pb =
+				new ProcessBuilder(torPath, "-f", configPath, OWNER, pid);
+		Map<String, String> env = pb.environment();
+		env.put("HOME", torDirectory.getAbsolutePath());
+		pb.directory(torDirectory);
+		pb.redirectErrorStream(true);
+		try {
+			torProcess = pb.start();
+		} catch (SecurityException | IOException e) {
+			throw new ServiceException(e);
+		}
+		try {
+			// Wait for the Tor process to start
+			waitForTorToStart(torProcess);
+			// Wait for the auth cookie file to be created/updated
+			long start = clock.currentTimeMillis();
+			while (cookieFile.length() < 32) {
+				if (clock.currentTimeMillis() - start > COOKIE_TIMEOUT_MS) {
+					LOG.warn("Auth cookie not created");
+					if (LOG.isInfoEnabled()) listFiles(torDirectory);
+					throw new ServiceException();
+				}
+				//noinspection BusyWait
+				Thread.sleep(COOKIE_POLLING_INTERVAL_MS);
+			}
+			LOG.info("Auth cookie created");
+		} catch (InterruptedException e) {
+			LOG.warn("Interrupted while starting Tor");
+			Thread.currentThread().interrupt();
+			throw new ServiceException();
+		}
+		try {
+			// Open a control connection and authenticate using the cookie file
+			controlSocket = new Socket("127.0.0.1", CONTROL_PORT);
+			controlConnection = new TorControlConnection(controlSocket);
+			controlConnection.authenticate(read(cookieFile));
+			// Tell Tor to exit when the control connection is closed
+			controlConnection.takeOwnership();
+			controlConnection.resetConf(singletonList(OWNER));
+			// Register to receive events from the Tor process
+			controlConnection.setEventHandler(this);
+			controlConnection.setEvents(asList(EVENTS));
+			// Check whether Tor has already bootstrapped
+			String info = controlConnection.getInfo("status/bootstrap-phase");
+			if (info != null && info.contains("PROGRESS=100")) {
+				LOG.info("Tor has already bootstrapped");
+				state.setBootstrapPercent(100);
+			}
+			// Check whether Tor has already built a circuit
+			info = controlConnection.getInfo("status/circuit-established");
+			if ("1".equals(info)) {
+				LOG.info("Tor has already built a circuit");
+				state.setCircuitBuilt(true);
+			}
+		} catch (IOException e) {
+			throw new ServiceException(e);
+		}
+		state.setStarted();
+		// Check whether we're online
+		updateConnectionStatus(networkManager.getNetworkStatus());
+		// Create a hidden service if necessary
+		ioExecutor.execute(() -> publishHiddenService(
+				String.valueOf(WebServerManager.PORT)));
+	}
+
+	private boolean assetsAreUpToDate() {
+		return doneFile.lastModified() > getLastUpdateTime();
+	}
+
+	private void installAssets() throws ServiceException {
+		if (architecture == null)
+			throw new ServiceException(
+					"Tor not supported on this architecture");
+		try {
+			// The done file may already exist from a previous installation
+			//noinspection ResultOfMethodCallIgnored
+			doneFile.delete();
+			installTorExecutable();
+			installObfs4Executable();
+			if (!doneFile.createNewFile())
+				LOG.warn("Failed to create done file");
+		} catch (IOException e) {
+			throw new ServiceException(e);
+		}
+	}
+
+	protected void extract(InputStream in, File dest) throws IOException {
+		OutputStream out = new FileOutputStream(dest);
+		copyAndClose(in, out);
+	}
+
+	protected void installTorExecutable() throws IOException {
+		info(LOG, () -> "Installing Tor binary for " + architecture);
+		File torFile = getTorExecutableFile();
+		extract(getTorInputStream(), torFile);
+		if (!torFile.setExecutable(true, true)) throw new IOException();
+	}
+
+	protected void installObfs4Executable() throws IOException {
+		info(LOG, () -> "Installing obfs4proxy binary for " + architecture);
+		File obfs4File = getObfs4ExecutableFile();
+		extract(getObfs4InputStream(), obfs4File);
+		if (!obfs4File.setExecutable(true, true)) throw new IOException();
+	}
+
+	private InputStream getTorInputStream() throws IOException {
+		InputStream in = resourceProvider
+				.getResourceInputStream("tor_" + architecture, ".zip");
+		ZipInputStream zin = new ZipInputStream(in);
+		if (zin.getNextEntry() == null) throw new IOException();
+		return zin;
+	}
+
+	private InputStream getObfs4InputStream() throws IOException {
+		InputStream in = resourceProvider
+				.getResourceInputStream("obfs4proxy_" + architecture, ".zip");
+		ZipInputStream zin = new ZipInputStream(in);
+		if (zin.getNextEntry() == null) throw new IOException();
+		return zin;
+	}
+
+	private static void append(StringBuilder strb, String name, Object value) {
+		strb.append(name);
+		strb.append(" ");
+		strb.append(value);
+		strb.append("\n");
+	}
+
+	private InputStream getConfigInputStream() {
+		File dataDirectory = new File(torDirectory, ".tor");
+		StringBuilder strb = new StringBuilder();
+		append(strb, "ControlPort", 59055);
+		append(strb, "CookieAuthentication", 1);
+		append(strb, "DataDirectory", dataDirectory.getAbsolutePath());
+		append(strb, "DisableNetwork", 1);
+		append(strb, "RunAsDaemon", 1);
+		append(strb, "SafeSocks", 1);
+		append(strb, "SocksPort", 59054);
+		strb.append("GeoIPFile\n");
+		strb.append("GeoIPv6File\n");
+		append(strb, "ConnectionPadding", 0);
+		String obfs4Path = getObfs4ExecutableFile().getAbsolutePath();
+		append(strb, "ClientTransportPlugin obfs4 exec", obfs4Path);
+		append(strb, "ClientTransportPlugin meek_lite exec", obfs4Path);
+		//noinspection CharsetObjectCanBeUsed
+		return new ByteArrayInputStream(
+				strb.toString().getBytes(Charset.forName("UTF-8")));
+	}
+
+	private void listFiles(File f) {
+		if (f.isDirectory()) {
+			File[] children = f.listFiles();
+			if (children != null) for (File child : children) listFiles(child);
+		} else {
+			LOG.info(f.getAbsolutePath() + " " + f.length());
+		}
+	}
+
+	private byte[] read(File f) throws IOException {
+		byte[] b = new byte[(int) f.length()];
+		FileInputStream in = new FileInputStream(f);
+		try {
+			int offset = 0;
+			while (offset < b.length) {
+				int read = in.read(b, offset, b.length - offset);
+				if (read == -1) throw new EOFException();
+				offset += read;
+			}
+			return b;
+		} finally {
+			tryToClose(in, LOG);
+		}
+	}
+
+	protected void waitForTorToStart(Process torProcess)
+			throws InterruptedException, ServiceException {
+		Scanner stdout = new Scanner(torProcess.getInputStream());
+		// Log the first line of stdout (contains Tor and library versions)
+		if (stdout.hasNextLine()) LOG.info(stdout.nextLine());
+		// Read the process's stdout (and redirected stderr) until it detaches
+		while (stdout.hasNextLine()) stdout.nextLine();
+		stdout.close();
+		// Wait for the process to detach or exit
+		int exit = torProcess.waitFor();
+		if (exit != 0) {
+			warn(LOG, () -> "Tor exited with value " + exit);
+			throw new ServiceException();
+		}
+	}
+
+	@IoExecutor
+	private void publishHiddenService(String port) {
+		if (!state.isTorRunning()) return;
+
+		Settings s;
+		try {
+			s = settingsManager.getSettings(SETTINGS_NAMESPACE);
+		} catch (DbException e) {
+			logException(LOG, e, "Error while retrieving settings");
+			s = new Settings();
+		}
+		String privateKey3 = s.get(HS_PRIVATE_KEY_V3);
+		createV3HiddenService(port, privateKey3);
+	}
+
+	@IoExecutor
+	private void createV3HiddenService(String port, @Nullable String privKey) {
+		LOG.info("Creating v3 hidden service");
+		Map<Integer, String> portLines = singletonMap(80, "127.0.0.1:" + port);
+		Map<String, String> response;
+		try {
+			// Use the control connection to set up the hidden service
+			if (privKey == null) {
+				response = controlConnection.addOnion("NEW:ED25519-V3",
+						portLines, null);
+			} else {
+				response = controlConnection.addOnion(privKey, portLines);
+			}
+		} catch (TorNotRunningException e) {
+			throw new RuntimeException(e);
+		} catch (IOException e) {
+			logException(LOG, e, "Error while add onion service");
+			return;
+		}
+		if (!response.containsKey(HS_ADDRESS)) {
+			LOG.warn("Tor did not return a hidden service address");
+			return;
+		}
+		if (privKey == null && !response.containsKey(HS_PRIVKEY)) {
+			LOG.warn("Tor did not return a private key");
+			return;
+		}
+		Settings s = new Settings();
+		String onion3 = response.get(HS_ADDRESS);
+		s.put(HS_ADDRESS_V3, onion3);
+		info(LOG, () -> "V3 hidden service " + scrubOnion(onion3));
+
+		if (privKey == null) {
+			s.put(HS_PRIVATE_KEY_V3, response.get(HS_PRIVKEY));
+			try {
+				settingsManager.mergeSettings(s, SETTINGS_NAMESPACE);
+			} catch (DbException e) {
+				logException(LOG, e, "Error while merging settings");
+			}
+		}
+	}
+
+	@Nullable
+	public String getHiddenServiceAddress() throws DbException {
+		Settings s = settingsManager.getSettings(SETTINGS_NAMESPACE);
+		return s.get(HS_ADDRESS_V3);
+	}
+
+	protected void enableNetwork(boolean enable) throws IOException {
+		if (!state.enableNetwork(enable)) return; // Unchanged
+		controlConnection.setConf("DisableNetwork", enable ? "0" : "1");
+	}
+
+	private void enableBridges(List<BridgeType> bridgeTypes)
+			throws IOException {
+		if (!state.setBridgeTypes(bridgeTypes)) return; // Unchanged
+		if (bridgeTypes.isEmpty()) {
+			controlConnection.setConf("UseBridges", "0");
+			controlConnection.resetConf(singletonList("Bridge"));
+		} else {
+			Collection<String> conf = new ArrayList<>();
+			conf.add("UseBridges 1");
+			for (BridgeType bridgeType : bridgeTypes) {
+				conf.addAll(circumventionProvider.getBridges(bridgeType));
+			}
+			controlConnection.setConf(conf);
+		}
+	}
+
+	@Override
+	public void stopService() {
+		state.setStopped();
+		if (controlSocket != null && controlConnection != null) {
+			try {
+				LOG.info("Stopping Tor");
+				controlConnection.shutdownTor("TERM");
+				controlSocket.close();
+			} catch (IOException e) {
+				logException(LOG, e,
+						"Error while sending tor shutdown instructions");
+			}
+		}
+	}
+
+	@Override
+	public void circuitStatus(String status, String id, String path) {
+		// In case of races between receiving CIRCUIT_ESTABLISHED and setting
+		// DisableNetwork, set our circuitBuilt flag if not already set
+		if (status.equals("BUILT") && state.setCircuitBuilt(true)) {
+			LOG.info("Circuit built");
+		}
+	}
+
+	@Override
+	public void streamStatus(String status, String id, String target) {
+	}
+
+	@Override
+	public void orConnStatus(String status, String orName) {
+		info(LOG, () -> "OR connection " + status);
+
+		if (status.equals("CONNECTED")) state.onOrConnectionConnected();
+		else if (status.equals("CLOSED")) state.onOrConnectionClosed();
+	}
+
+	@Override
+	public void bandwidthUsed(long read, long written) {
+	}
+
+	@Override
+	public void newDescriptors(List<String> orList) {
+	}
+
+	@Override
+	public void message(String severity, String msg) {
+		info(LOG, () -> severity + " " + msg);
+		if (severity.equals("NOTICE")) {
+			Matcher matcher = bootstrapPattern.matcher(msg);
+			if (matcher.matches()) {
+				String percentStr = matcher.group(1);
+				int percent = Integer.parseInt(percentStr);
+				state.setBootstrapPercent(percent);
+			}
+		} else if (severity.equals("WARN")) {
+			Matcher matcher = clockSkewPattern.matcher(msg);
+			if (matcher.find()) state.setClockSkewed();
+		}
+	}
+
+	@Override
+	public void unrecognized(String type, String msg) {
+		if (type.equals("STATUS_CLIENT")) {
+			handleClientStatus(removeSeverity(msg));
+		} else if (type.equals("STATUS_GENERAL")) {
+			handleGeneralStatus(removeSeverity(msg));
+		} else if (type.equals("HS_DESC") && msg.startsWith("UPLOADED")) {
+			LOG.info("V3 descriptor uploaded");
+			state.onServiceDescriptorUploaded();
+		}
+	}
+
+	private String removeSeverity(String msg) {
+		return msg.replaceFirst("[^ ]+ ", "");
+	}
+
+	private void handleClientStatus(String msg) {
+		if (msg.startsWith("BOOTSTRAP PROGRESS=100")) {
+			LOG.info("Bootstrapped");
+			state.setBootstrapPercent(100);
+		} else if (msg.startsWith("CIRCUIT_ESTABLISHED")) {
+			if (state.setCircuitBuilt(true)) {
+				LOG.info("Circuit built");
+			}
+		} else if (msg.startsWith("CIRCUIT_NOT_ESTABLISHED")) {
+			if (state.setCircuitBuilt(false)) {
+				LOG.info("Circuit not built");
+				// TODO: Disable and re-enable network to prompt Tor to rebuild
+				//  its guard/bridge connections? This will also close any
+				//  established circuits, which might still be functioning
+			}
+		}
+	}
+
+	private void handleGeneralStatus(String msg) {
+		if (msg.startsWith("CLOCK_JUMPED")) {
+			Long time = parseLongArgument(msg, "TIME");
+			if (time != null) {
+				warn(LOG, () -> "Clock jumped " + time + " seconds");
+			}
+		} else if (msg.startsWith("CLOCK_SKEW")) {
+			Long skew = parseLongArgument(msg, "SKEW");
+			if (skew != null) {
+				warn(LOG, () -> "Clock is skewed by " + skew + " seconds");
+			}
+		}
+	}
+
+	@Nullable
+	private Long parseLongArgument(String msg, String argName) {
+		String[] args = msg.split(" ");
+		for (String arg : args) {
+			if (arg.startsWith(argName + "=")) {
+				try {
+					return Long.parseLong(arg.substring(argName.length() + 1));
+				} catch (NumberFormatException e) {
+					break;
+				}
+			}
+		}
+		warn(LOG, () -> "Failed to parse " + argName + " from '" + msg + "'");
+		return null;
+	}
+
+	@Override
+	public void controlConnectionClosed() {
+		if (state.isTorRunning()) {
+			throw new RuntimeException("Control connection closed");
+		}
+	}
+
+	@Override
+	public void eventOccurred(Event e) {
+		if (e instanceof NetworkStatusEvent) {
+			updateConnectionStatus(((NetworkStatusEvent) e).getStatus());
+		}
+	}
+
+	private void updateConnectionStatus(NetworkStatus status) {
+		connectionStatusExecutor.execute(() -> {
+			if (!state.isTorRunning()) return;
+			boolean online = status.isConnected();
+			boolean wifi = status.isWifi();
+			boolean ipv6Only = status.isIpv6Only();
+			String country = locationUtils.getCurrentCountry();
+			boolean bridgesWork = circumventionProvider.doBridgesWork(country);
+
+			if (LOG.isInfoEnabled()) {
+				LOG.info("Online: " + online + ", wifi: " + wifi
+						+ ", IPv6 only: " + ipv6Only);
+				if (country.isEmpty()) LOG.info("Country code unknown");
+				else LOG.info("Country code: " + country);
+			}
+
+			boolean enableNetwork = false, enableConnectionPadding = false;
+			List<BridgeType> bridgeTypes = emptyList();
+
+			if (!online) {
+				LOG.info("Disabling network, device is offline");
+			} else {
+				LOG.info("Enabling network");
+				enableNetwork = true;
+				if (bridgesWork) {
+					if (ipv6Only) {
+						bridgeTypes = singletonList(MEEK);
+					} else {
+						bridgeTypes = circumventionProvider
+								.getSuitableBridgeTypes(country);
+					}
+					if (LOG.isInfoEnabled()) {
+						LOG.info("Using bridge types " + bridgeTypes);
+					}
+				} else {
+					LOG.info("Not using bridges");
+				}
+				if (wifi) {
+					LOG.info("Enabling connection padding");
+					enableConnectionPadding = true;
+				} else {
+					LOG.info("Disabling connection padding");
+				}
+			}
+
+			try {
+				if (enableNetwork) {
+					enableBridges(bridgeTypes);
+					enableConnectionPadding(enableConnectionPadding);
+					enableIpv6(ipv6Only);
+				}
+				enableNetwork(enableNetwork);
+			} catch (IOException e) {
+				logException(LOG, e, "Error enabling network");
+			}
+		});
+	}
+
+	private void enableConnectionPadding(boolean enable) throws IOException {
+		if (!state.enableConnectionPadding(enable)) return; // Unchanged
+		try {
+			controlConnection.setConf("ConnectionPadding", enable ? "1" : "0");
+		} catch (TorNotRunningException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	private void enableIpv6(boolean enable) throws IOException {
+		if (!state.enableIpv6(enable)) return; // Unchanged
+		try {
+			controlConnection.setConf("ClientUseIPv4", enable ? "0" : "1");
+			controlConnection.setConf("ClientUseIPv6", enable ? "1" : "0");
+		} catch (TorNotRunningException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	@ThreadSafe
+	protected static class PluginState {
+
+		private final MutableStateFlow<TorState> state =
+				MutableStateFlow(TorState.StartingStopping.INSTANCE);
+
+		@GuardedBy("this")
+		private boolean started = false,
+				stopped = false,
+				networkInitialised = false,
+				networkEnabled = false,
+				paddingEnabled = false,
+				ipv6Enabled = false,
+				circuitBuilt = false,
+				clockSkewed = false;
+		@GuardedBy("this")
+		private int bootstrapPercent = 0, numServiceUploads = 0;
+
+		@GuardedBy("this")
+		private int orConnectionsConnected = 0;
+
+		@GuardedBy("this")
+		private List<BridgeType> bridgeTypes = emptyList();
+
+		synchronized void setStarted() {
+			started = true;
+			state.setValue(getCurrentState());
+		}
+
+		@SuppressWarnings("BooleanMethodIsAlwaysInverted")
+		synchronized boolean isTorRunning() {
+			return started && !stopped;
+		}
+
+		synchronized void setStopped() {
+			stopped = true;
+			state.setValue(getCurrentState());
+		}
+
+		synchronized void setBootstrapPercent(int percent) {
+			if (percent < 0 || percent > 100) {
+				throw new IllegalArgumentException("percent: " + percent);
+			}
+			bootstrapPercent = percent;
+			if (percent == 100) clockSkewed = false;
+			state.setValue(getCurrentState());
+		}
+
+		synchronized void setClockSkewed() {
+			clockSkewed = true;
+			state.setValue(getCurrentState());
+		}
+
+		/**
+		 * Sets the `circuitBuilt` flag and returns true if the flag has
+		 * changed.
+		 */
+		private synchronized boolean setCircuitBuilt(boolean built) {
+			if (built == circuitBuilt) return false; // Unchanged
+			circuitBuilt = built;
+			if (bootstrapPercent == 100) clockSkewed = false;
+			state.setValue(getCurrentState());
+			return true; // Changed
+		}
+
+		synchronized void onServiceDescriptorUploaded() {
+			numServiceUploads++;
+			state.setValue(getCurrentState());
+		}
+
+		/**
+		 * Sets the `networkEnabled` flag and returns true if the flag has
+		 * changed.
+		 */
+		synchronized boolean enableNetwork(boolean enable) {
+			boolean wasInitialised = networkInitialised;
+			boolean wasEnabled = networkEnabled;
+			networkInitialised = true;
+			networkEnabled = enable;
+			if (!enable) circuitBuilt = false;
+			if (!wasInitialised || enable != wasEnabled) {
+				state.setValue(getCurrentState());
+			}
+			return enable != wasEnabled;
+		}
+
+		/**
+		 * Sets the `paddingEnabled` flag and returns true if the flag has
+		 * changed. Doesn't affect getState().
+		 */
+		private synchronized boolean enableConnectionPadding(boolean enable) {
+			if (enable == paddingEnabled) return false; // Unchanged
+			paddingEnabled = enable;
+			return true; // Changed
+		}
+
+		/**
+		 * Sets the `ipv6Enabled` flag and returns true if the flag has
+		 * changed. Doesn't affect getState().
+		 */
+		private synchronized boolean enableIpv6(boolean enable) {
+			if (enable == ipv6Enabled) return false; // Unchanged
+			ipv6Enabled = enable;
+			return true; // Changed
+		}
+
+		/**
+		 * Sets the list of bridge types being used and returns true if the
+		 * list has changed. The list is empty if bridges are disabled.
+		 * Doesn't affect getState().
+		 */
+		private synchronized boolean setBridgeTypes(List<BridgeType> types) {
+			if (types.equals(bridgeTypes)) return false; // Unchanged
+			bridgeTypes = types;
+			return true; // Changed
+		}
+
+		private synchronized TorState getCurrentState() {
+			if (!started || stopped) {
+				return TorState.StartingStopping.INSTANCE;
+			}
+			if (!networkInitialised) {
+				return new TorState.Enabling(bootstrapPercent);
+			}
+			if (!networkEnabled) return TorState.Inactive.INSTANCE;
+			if (clockSkewed) return TorState.ClockSkewed.INSTANCE;
+			if (bootstrapPercent == 100 && circuitBuilt &&
+					orConnectionsConnected > 0) {
+				return (numServiceUploads >= HS_DESC_UPLOADS) ?
+						TorState.Published.INSTANCE : TorState.Active.INSTANCE;
+			} else return new TorState.Enabling(bootstrapPercent);
+		}
+
+		private synchronized void onOrConnectionConnected() {
+			int oldConnected = orConnectionsConnected;
+			orConnectionsConnected++;
+			logOrConnections();
+			if (oldConnected == 0) state.setValue(getCurrentState());
+		}
+
+		private synchronized void onOrConnectionClosed() {
+			int oldConnected = orConnectionsConnected;
+			orConnectionsConnected--;
+			if (orConnectionsConnected < 0) {
+				LOG.warn("Count was zero before connection closed");
+				orConnectionsConnected = 0;
+			}
+			logOrConnections();
+			if (orConnectionsConnected == 0 && oldConnected != 0) {
+				state.setValue(getCurrentState());
+			}
+		}
+
+		@GuardedBy("this")
+		private void logOrConnections() {
+			info(LOG, () ->
+					orConnectionsConnected + " OR connections connected");
+		}
+
+	}
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorPlugin.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorPlugin.java
index c79c673afe9aa67bded14f85f898322b9fd83aed..ba71ad303f9915f8bb326d6ef2bb61c011eabbe4 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorPlugin.java
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorPlugin.java
@@ -1,855 +1,14 @@
-/*
- *     Briar Mailbox
- *     Copyright (C) 2021-2022  The Briar Project
- *
- *     This program is free software: you can redistribute it and/or modify
- *     it under the terms of the GNU Affero General Public License as
- *     published by the Free Software Foundation, either version 3 of the
- *     License, or (at your option) any later version.
- *
- *     This program is distributed in the hope that it will be useful,
- *     but WITHOUT ANY WARRANTY; without even the implied warranty of
- *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *     GNU Affero General Public License for more details.
- *
- *     You should have received a copy of the GNU Affero General Public License
- *     along with this program.  If not, see <https://www.gnu.org/licenses/>.
- *
- */
-
 package org.briarproject.mailbox.core.tor;
 
-import net.freehaven.tor.control.EventHandler;
-import net.freehaven.tor.control.TorControlConnection;
-import net.freehaven.tor.control.TorNotRunningException;
-
-import org.briarproject.mailbox.core.PoliteExecutor;
 import org.briarproject.mailbox.core.db.DbException;
-import org.briarproject.mailbox.core.event.Event;
-import org.briarproject.mailbox.core.event.EventListener;
-import org.briarproject.mailbox.core.lifecycle.IoExecutor;
 import org.briarproject.mailbox.core.lifecycle.Service;
-import org.briarproject.mailbox.core.lifecycle.ServiceException;
-import org.briarproject.mailbox.core.server.WebServerManager;
-import org.briarproject.mailbox.core.settings.Settings;
-import org.briarproject.mailbox.core.settings.SettingsManager;
-import org.briarproject.mailbox.core.system.Clock;
-import org.briarproject.mailbox.core.system.LocationUtils;
-import org.briarproject.mailbox.core.system.ResourceProvider;
-import org.briarproject.mailbox.core.tor.CircumventionProvider.BridgeType;
-import org.slf4j.Logger;
-
-import java.io.ByteArrayInputStream;
-import java.io.EOFException;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.net.Socket;
-import java.nio.charset.Charset;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import java.util.Map;
-import java.util.Scanner;
-import java.util.concurrent.Executor;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import java.util.zip.ZipInputStream;
 
-import javax.annotation.Nullable;
-import javax.annotation.concurrent.GuardedBy;
-import javax.annotation.concurrent.ThreadSafe;
-
-import kotlinx.coroutines.flow.MutableStateFlow;
 import kotlinx.coroutines.flow.StateFlow;
 
-import static java.util.Arrays.asList;
-import static java.util.Collections.emptyList;
-import static java.util.Collections.singletonList;
-import static java.util.Collections.singletonMap;
-import static kotlinx.coroutines.flow.StateFlowKt.MutableStateFlow;
-import static net.freehaven.tor.control.TorControlCommands.HS_ADDRESS;
-import static net.freehaven.tor.control.TorControlCommands.HS_PRIVKEY;
-import static org.briarproject.mailbox.core.tor.CircumventionProvider.BridgeType.MEEK;
-import static org.briarproject.mailbox.core.tor.TorConstants.CONTROL_PORT;
-import static org.briarproject.mailbox.core.tor.TorConstants.HS_ADDRESS_V3;
-import static org.briarproject.mailbox.core.tor.TorConstants.HS_PRIVATE_KEY_V3;
-import static org.briarproject.mailbox.core.tor.TorConstants.SETTINGS_NAMESPACE;
-import static org.briarproject.mailbox.core.util.IoUtils.copyAndClose;
-import static org.briarproject.mailbox.core.util.IoUtils.tryToClose;
-import static org.briarproject.mailbox.core.util.LogUtils.info;
-import static org.briarproject.mailbox.core.util.LogUtils.logException;
-import static org.briarproject.mailbox.core.util.LogUtils.warn;
-import static org.briarproject.mailbox.core.util.PrivacyUtils.scrubOnion;
-import static org.slf4j.LoggerFactory.getLogger;
-
-public abstract class TorPlugin
-		implements Service, EventHandler, EventListener {
-
-	private static final Logger LOG = getLogger(TorPlugin.class);
-
-	private static final String[] EVENTS = {
-			"CIRC",
-			"ORCONN",
-			"STATUS_GENERAL",
-			"STATUS_CLIENT",
-			"HS_DESC",
-			"NOTICE",
-			"WARN",
-			"ERR"
-	};
-	private static final String OWNER = "__OwningControllerProcess";
-	private static final int COOKIE_TIMEOUT_MS = 3000;
-	private static final int COOKIE_POLLING_INTERVAL_MS = 200;
-	/**
-	 * The number of uploads of our onion service descriptor we wait for
-	 * before we consider our onion service to be published.
-	 * In reality, the actual reachability is more complicated,
-	 * but this might be a reasonable heuristic.
-	 */
-	private static final int HS_DESC_UPLOADS = 1;
-	private final Pattern bootstrapPattern =
-			Pattern.compile("^Bootstrapped ([0-9]{1,3})%.*$");
-	private final Pattern clockSkewPattern = Pattern.compile("CLOCK_SKEW");
-
-	private final Executor ioExecutor;
-	private final Executor connectionStatusExecutor;
-	private final SettingsManager settingsManager;
-	private final NetworkManager networkManager;
-	private final LocationUtils locationUtils;
-	private final Clock clock;
-	@Nullable
-	private final String architecture;
-	private final CircumventionProvider circumventionProvider;
-	private final ResourceProvider resourceProvider;
-	private final File torDirectory, configFile;
-	private final File doneFile, cookieFile;
-	private final AtomicBoolean used = new AtomicBoolean(false);
-
-	protected final PluginState state = new PluginState();
-
-	private volatile Socket controlSocket = null;
-	private volatile TorControlConnection controlConnection = null;
-
-	protected abstract int getProcessId();
-
-	protected abstract long getLastUpdateTime();
-
-	TorPlugin(Executor ioExecutor,
-			SettingsManager settingsManager,
-			NetworkManager networkManager,
-			LocationUtils locationUtils,
-			Clock clock,
-			ResourceProvider resourceProvider,
-			CircumventionProvider circumventionProvider,
-			@Nullable String architecture,
-			File torDirectory) {
-		this.ioExecutor = ioExecutor;
-		this.settingsManager = settingsManager;
-		this.networkManager = networkManager;
-		this.locationUtils = locationUtils;
-		this.clock = clock;
-		this.resourceProvider = resourceProvider;
-		this.circumventionProvider = circumventionProvider;
-		this.architecture = architecture;
-		this.torDirectory = torDirectory;
-		configFile = new File(torDirectory, "torrc");
-		doneFile = new File(torDirectory, "done");
-		cookieFile = new File(torDirectory, ".tor/control_auth_cookie");
-		// Don't execute more than one connection status check at a time
-		connectionStatusExecutor =
-				new PoliteExecutor("TorPlugin", ioExecutor, 1);
-	}
-
-	protected File getTorExecutableFile() {
-		return new File(torDirectory, "tor");
-	}
-
-	protected File getObfs4ExecutableFile() {
-		return new File(torDirectory, "obfs4proxy");
-	}
-
-	public StateFlow<TorState> getState() {
-		return state.state;
-	}
-
-	@Override
-	public void startService() throws ServiceException {
-		if (used.getAndSet(true)) throw new IllegalStateException();
-		if (!torDirectory.exists()) {
-			if (!torDirectory.mkdirs()) {
-				LOG.warn("Could not create Tor directory.");
-				throw new ServiceException();
-			}
-		}
-		try {
-			// Install or update the assets if necessary
-			if (!assetsAreUpToDate()) installAssets();
-			// Start from the default config every time
-			extract(getConfigInputStream(), configFile);
-		} catch (IOException e) {
-			throw new ServiceException(e);
-		}
-		if (cookieFile.exists() && !cookieFile.delete())
-			LOG.warn("Old auth cookie not deleted");
-		// Start a new Tor process
-		LOG.info("Starting Tor");
-		File torFile = getTorExecutableFile();
-		String torPath = torFile.getAbsolutePath();
-		String configPath = configFile.getAbsolutePath();
-		String pid = String.valueOf(getProcessId());
-		Process torProcess;
-		ProcessBuilder pb =
-				new ProcessBuilder(torPath, "-f", configPath, OWNER, pid);
-		Map<String, String> env = pb.environment();
-		env.put("HOME", torDirectory.getAbsolutePath());
-		pb.directory(torDirectory);
-		pb.redirectErrorStream(true);
-		try {
-			torProcess = pb.start();
-		} catch (SecurityException | IOException e) {
-			throw new ServiceException(e);
-		}
-		try {
-			// Wait for the Tor process to start
-			waitForTorToStart(torProcess);
-			// Wait for the auth cookie file to be created/updated
-			long start = clock.currentTimeMillis();
-			while (cookieFile.length() < 32) {
-				if (clock.currentTimeMillis() - start > COOKIE_TIMEOUT_MS) {
-					LOG.warn("Auth cookie not created");
-					if (LOG.isInfoEnabled()) listFiles(torDirectory);
-					throw new ServiceException();
-				}
-				//noinspection BusyWait
-				Thread.sleep(COOKIE_POLLING_INTERVAL_MS);
-			}
-			LOG.info("Auth cookie created");
-		} catch (InterruptedException e) {
-			LOG.warn("Interrupted while starting Tor");
-			Thread.currentThread().interrupt();
-			throw new ServiceException();
-		}
-		try {
-			// Open a control connection and authenticate using the cookie file
-			controlSocket = new Socket("127.0.0.1", CONTROL_PORT);
-			controlConnection = new TorControlConnection(controlSocket);
-			controlConnection.authenticate(read(cookieFile));
-			// Tell Tor to exit when the control connection is closed
-			controlConnection.takeOwnership();
-			controlConnection.resetConf(singletonList(OWNER));
-			// Register to receive events from the Tor process
-			controlConnection.setEventHandler(this);
-			controlConnection.setEvents(asList(EVENTS));
-			// Check whether Tor has already bootstrapped
-			String info = controlConnection.getInfo("status/bootstrap-phase");
-			if (info != null && info.contains("PROGRESS=100")) {
-				LOG.info("Tor has already bootstrapped");
-				state.setBootstrapPercent(100);
-			}
-			// Check whether Tor has already built a circuit
-			info = controlConnection.getInfo("status/circuit-established");
-			if ("1".equals(info)) {
-				LOG.info("Tor has already built a circuit");
-				state.setCircuitBuilt(true);
-			}
-		} catch (IOException e) {
-			throw new ServiceException(e);
-		}
-		state.setStarted();
-		// Check whether we're online
-		updateConnectionStatus(networkManager.getNetworkStatus());
-		// Create a hidden service if necessary
-		ioExecutor.execute(() -> publishHiddenService(
-				String.valueOf(WebServerManager.PORT)));
-	}
-
-	private boolean assetsAreUpToDate() {
-		return doneFile.lastModified() > getLastUpdateTime();
-	}
-
-	private void installAssets() throws ServiceException {
-		if (architecture == null)
-			throw new ServiceException(
-					"Tor not supported on this architecture");
-		try {
-			// The done file may already exist from a previous installation
-			//noinspection ResultOfMethodCallIgnored
-			doneFile.delete();
-			installTorExecutable();
-			installObfs4Executable();
-			if (!doneFile.createNewFile())
-				LOG.warn("Failed to create done file");
-		} catch (IOException e) {
-			throw new ServiceException(e);
-		}
-	}
-
-	protected void extract(InputStream in, File dest) throws IOException {
-		OutputStream out = new FileOutputStream(dest);
-		copyAndClose(in, out);
-	}
-
-	protected void installTorExecutable() throws IOException {
-		info(LOG, () -> "Installing Tor binary for " + architecture);
-		File torFile = getTorExecutableFile();
-		extract(getTorInputStream(), torFile);
-		if (!torFile.setExecutable(true, true)) throw new IOException();
-	}
-
-	protected void installObfs4Executable() throws IOException {
-		info(LOG, () -> "Installing obfs4proxy binary for " + architecture);
-		File obfs4File = getObfs4ExecutableFile();
-		extract(getObfs4InputStream(), obfs4File);
-		if (!obfs4File.setExecutable(true, true)) throw new IOException();
-	}
-
-	private InputStream getTorInputStream() throws IOException {
-		InputStream in = resourceProvider
-				.getResourceInputStream("tor_" + architecture, ".zip");
-		ZipInputStream zin = new ZipInputStream(in);
-		if (zin.getNextEntry() == null) throw new IOException();
-		return zin;
-	}
-
-	private InputStream getObfs4InputStream() throws IOException {
-		InputStream in = resourceProvider
-				.getResourceInputStream("obfs4proxy_" + architecture, ".zip");
-		ZipInputStream zin = new ZipInputStream(in);
-		if (zin.getNextEntry() == null) throw new IOException();
-		return zin;
-	}
-
-	private static void append(StringBuilder strb, String name, Object value) {
-		strb.append(name);
-		strb.append(" ");
-		strb.append(value);
-		strb.append("\n");
-	}
-
-	private InputStream getConfigInputStream() {
-		File dataDirectory = new File(torDirectory, ".tor");
-		StringBuilder strb = new StringBuilder();
-		append(strb, "ControlPort", 59055);
-		append(strb, "CookieAuthentication", 1);
-		append(strb, "DataDirectory", dataDirectory.getAbsolutePath());
-		append(strb, "DisableNetwork", 1);
-		append(strb, "RunAsDaemon", 1);
-		append(strb, "SafeSocks", 1);
-		append(strb, "SocksPort", 59054);
-		strb.append("GeoIPFile\n");
-		strb.append("GeoIPv6File\n");
-		append(strb, "ConnectionPadding", 0);
-		String obfs4Path = getObfs4ExecutableFile().getAbsolutePath();
-		append(strb, "ClientTransportPlugin obfs4 exec", obfs4Path);
-		append(strb, "ClientTransportPlugin meek_lite exec", obfs4Path);
-		//noinspection CharsetObjectCanBeUsed
-		return new ByteArrayInputStream(
-				strb.toString().getBytes(Charset.forName("UTF-8")));
-	}
-
-	private void listFiles(File f) {
-		if (f.isDirectory()) {
-			File[] children = f.listFiles();
-			if (children != null) for (File child : children) listFiles(child);
-		} else {
-			LOG.info(f.getAbsolutePath() + " " + f.length());
-		}
-	}
-
-	private byte[] read(File f) throws IOException {
-		byte[] b = new byte[(int) f.length()];
-		FileInputStream in = new FileInputStream(f);
-		try {
-			int offset = 0;
-			while (offset < b.length) {
-				int read = in.read(b, offset, b.length - offset);
-				if (read == -1) throw new EOFException();
-				offset += read;
-			}
-			return b;
-		} finally {
-			tryToClose(in, LOG);
-		}
-	}
-
-	protected void waitForTorToStart(Process torProcess)
-			throws InterruptedException, ServiceException {
-		Scanner stdout = new Scanner(torProcess.getInputStream());
-		// Log the first line of stdout (contains Tor and library versions)
-		if (stdout.hasNextLine()) LOG.info(stdout.nextLine());
-		// Read the process's stdout (and redirected stderr) until it detaches
-		while (stdout.hasNextLine()) stdout.nextLine();
-		stdout.close();
-		// Wait for the process to detach or exit
-		int exit = torProcess.waitFor();
-		if (exit != 0) {
-			warn(LOG, () -> "Tor exited with value " + exit);
-			throw new ServiceException();
-		}
-	}
-
-	@IoExecutor
-	private void publishHiddenService(String port) {
-		if (!state.isTorRunning()) return;
-
-		Settings s;
-		try {
-			s = settingsManager.getSettings(SETTINGS_NAMESPACE);
-		} catch (DbException e) {
-			logException(LOG, e, "Error while retrieving settings");
-			s = new Settings();
-		}
-		String privateKey3 = s.get(HS_PRIVATE_KEY_V3);
-		createV3HiddenService(port, privateKey3);
-	}
-
-	@IoExecutor
-	private void createV3HiddenService(String port, @Nullable String privKey) {
-		LOG.info("Creating v3 hidden service");
-		Map<Integer, String> portLines = singletonMap(80, "127.0.0.1:" + port);
-		Map<String, String> response;
-		try {
-			// Use the control connection to set up the hidden service
-			if (privKey == null) {
-				response = controlConnection.addOnion("NEW:ED25519-V3",
-						portLines, null);
-			} else {
-				response = controlConnection.addOnion(privKey, portLines);
-			}
-		} catch (TorNotRunningException e) {
-			throw new RuntimeException(e);
-		} catch (IOException e) {
-			logException(LOG, e, "Error while add onion service");
-			return;
-		}
-		if (!response.containsKey(HS_ADDRESS)) {
-			LOG.warn("Tor did not return a hidden service address");
-			return;
-		}
-		if (privKey == null && !response.containsKey(HS_PRIVKEY)) {
-			LOG.warn("Tor did not return a private key");
-			return;
-		}
-		Settings s = new Settings();
-		String onion3 = response.get(HS_ADDRESS);
-		s.put(HS_ADDRESS_V3, onion3);
-		info(LOG, () -> "V3 hidden service " + scrubOnion(onion3));
-
-		if (privKey == null) {
-			s.put(HS_PRIVATE_KEY_V3, response.get(HS_PRIVKEY));
-			try {
-				settingsManager.mergeSettings(s, SETTINGS_NAMESPACE);
-			} catch (DbException e) {
-				logException(LOG, e, "Error while merging settings");
-			}
-		}
-	}
-
-	@Nullable
-	public String getHiddenServiceAddress() throws DbException {
-		Settings s = settingsManager.getSettings(SETTINGS_NAMESPACE);
-		return s.get(HS_ADDRESS_V3);
-	}
-
-	protected void enableNetwork(boolean enable) throws IOException {
-		if (!state.enableNetwork(enable)) return; // Unchanged
-		controlConnection.setConf("DisableNetwork", enable ? "0" : "1");
-	}
-
-	private void enableBridges(List<BridgeType> bridgeTypes)
-			throws IOException {
-		if (!state.setBridgeTypes(bridgeTypes)) return; // Unchanged
-		if (bridgeTypes.isEmpty()) {
-			controlConnection.setConf("UseBridges", "0");
-			controlConnection.resetConf(singletonList("Bridge"));
-		} else {
-			Collection<String> conf = new ArrayList<>();
-			conf.add("UseBridges 1");
-			for (BridgeType bridgeType : bridgeTypes) {
-				conf.addAll(circumventionProvider.getBridges(bridgeType));
-			}
-			controlConnection.setConf(conf);
-		}
-	}
-
-	@Override
-	public void stopService() {
-		state.setStopped();
-		if (controlSocket != null && controlConnection != null) {
-			try {
-				LOG.info("Stopping Tor");
-				controlConnection.shutdownTor("TERM");
-				controlSocket.close();
-			} catch (IOException e) {
-				logException(LOG, e,
-						"Error while sending tor shutdown instructions");
-			}
-		}
-	}
-
-	@Override
-	public void circuitStatus(String status, String id, String path) {
-		// In case of races between receiving CIRCUIT_ESTABLISHED and setting
-		// DisableNetwork, set our circuitBuilt flag if not already set
-		if (status.equals("BUILT") && state.setCircuitBuilt(true)) {
-			LOG.info("Circuit built");
-		}
-	}
-
-	@Override
-	public void streamStatus(String status, String id, String target) {
-	}
-
-	@Override
-	public void orConnStatus(String status, String orName) {
-		info(LOG, () -> "OR connection " + status);
-
-		if (status.equals("CONNECTED")) state.onOrConnectionConnected();
-		else if (status.equals("CLOSED")) state.onOrConnectionClosed();
-	}
-
-	@Override
-	public void bandwidthUsed(long read, long written) {
-	}
-
-	@Override
-	public void newDescriptors(List<String> orList) {
-	}
-
-	@Override
-	public void message(String severity, String msg) {
-		info(LOG, () -> severity + " " + msg);
-		if (severity.equals("NOTICE")) {
-			Matcher matcher = bootstrapPattern.matcher(msg);
-			if (matcher.matches()) {
-				String percentStr = matcher.group(1);
-				int percent = Integer.parseInt(percentStr);
-				state.setBootstrapPercent(percent);
-			}
-		} else if (severity.equals("WARN")) {
-			Matcher matcher = clockSkewPattern.matcher(msg);
-			if (matcher.find()) state.setClockSkewed();
-		}
-	}
-
-	@Override
-	public void unrecognized(String type, String msg) {
-		if (type.equals("STATUS_CLIENT")) {
-			handleClientStatus(removeSeverity(msg));
-		} else if (type.equals("STATUS_GENERAL")) {
-			handleGeneralStatus(removeSeverity(msg));
-		} else if (type.equals("HS_DESC") && msg.startsWith("UPLOADED")) {
-			LOG.info("V3 descriptor uploaded");
-			state.onServiceDescriptorUploaded();
-		}
-	}
-
-	private String removeSeverity(String msg) {
-		return msg.replaceFirst("[^ ]+ ", "");
-	}
-
-	private void handleClientStatus(String msg) {
-		if (msg.startsWith("BOOTSTRAP PROGRESS=100")) {
-			LOG.info("Bootstrapped");
-			state.setBootstrapPercent(100);
-		} else if (msg.startsWith("CIRCUIT_ESTABLISHED")) {
-			if (state.setCircuitBuilt(true)) {
-				LOG.info("Circuit built");
-			}
-		} else if (msg.startsWith("CIRCUIT_NOT_ESTABLISHED")) {
-			if (state.setCircuitBuilt(false)) {
-				LOG.info("Circuit not built");
-				// TODO: Disable and re-enable network to prompt Tor to rebuild
-				//  its guard/bridge connections? This will also close any
-				//  established circuits, which might still be functioning
-			}
-		}
-	}
-
-	private void handleGeneralStatus(String msg) {
-		if (msg.startsWith("CLOCK_JUMPED")) {
-			Long time = parseLongArgument(msg, "TIME");
-			if (time != null) {
-				warn(LOG, () -> "Clock jumped " + time + " seconds");
-			}
-		} else if (msg.startsWith("CLOCK_SKEW")) {
-			Long skew = parseLongArgument(msg, "SKEW");
-			if (skew != null) {
-				warn(LOG, () -> "Clock is skewed by " + skew + " seconds");
-			}
-		}
-	}
-
-	@Nullable
-	private Long parseLongArgument(String msg, String argName) {
-		String[] args = msg.split(" ");
-		for (String arg : args) {
-			if (arg.startsWith(argName + "=")) {
-				try {
-					return Long.parseLong(arg.substring(argName.length() + 1));
-				} catch (NumberFormatException e) {
-					break;
-				}
-			}
-		}
-		warn(LOG, () -> "Failed to parse " + argName + " from '" + msg + "'");
-		return null;
-	}
-
-	@Override
-	public void controlConnectionClosed() {
-		if (state.isTorRunning()) {
-			throw new RuntimeException("Control connection closed");
-		}
-	}
-
-	@Override
-	public void eventOccurred(Event e) {
-		if (e instanceof NetworkStatusEvent) {
-			updateConnectionStatus(((NetworkStatusEvent) e).getStatus());
-		}
-	}
-
-	private void updateConnectionStatus(NetworkStatus status) {
-		connectionStatusExecutor.execute(() -> {
-			if (!state.isTorRunning()) return;
-			boolean online = status.isConnected();
-			boolean wifi = status.isWifi();
-			boolean ipv6Only = status.isIpv6Only();
-			String country = locationUtils.getCurrentCountry();
-			boolean bridgesWork = circumventionProvider.doBridgesWork(country);
-
-			if (LOG.isInfoEnabled()) {
-				LOG.info("Online: " + online + ", wifi: " + wifi
-						+ ", IPv6 only: " + ipv6Only);
-				if (country.isEmpty()) LOG.info("Country code unknown");
-				else LOG.info("Country code: " + country);
-			}
-
-			boolean enableNetwork = false, enableConnectionPadding = false;
-			List<BridgeType> bridgeTypes = emptyList();
-
-			if (!online) {
-				LOG.info("Disabling network, device is offline");
-			} else {
-				LOG.info("Enabling network");
-				enableNetwork = true;
-				if (bridgesWork) {
-					if (ipv6Only) {
-						bridgeTypes = singletonList(MEEK);
-					} else {
-						bridgeTypes = circumventionProvider
-								.getSuitableBridgeTypes(country);
-					}
-					if (LOG.isInfoEnabled()) {
-						LOG.info("Using bridge types " + bridgeTypes);
-					}
-				} else {
-					LOG.info("Not using bridges");
-				}
-				if (wifi) {
-					LOG.info("Enabling connection padding");
-					enableConnectionPadding = true;
-				} else {
-					LOG.info("Disabling connection padding");
-				}
-			}
-
-			try {
-				if (enableNetwork) {
-					enableBridges(bridgeTypes);
-					enableConnectionPadding(enableConnectionPadding);
-					enableIpv6(ipv6Only);
-				}
-				enableNetwork(enableNetwork);
-			} catch (IOException e) {
-				logException(LOG, e, "Error enabling network");
-			}
-		});
-	}
-
-	private void enableConnectionPadding(boolean enable) throws IOException {
-		if (!state.enableConnectionPadding(enable)) return; // Unchanged
-		try {
-			controlConnection.setConf("ConnectionPadding", enable ? "1" : "0");
-		} catch (TorNotRunningException e) {
-			throw new RuntimeException(e);
-		}
-	}
-
-	private void enableIpv6(boolean enable) throws IOException {
-		if (!state.enableIpv6(enable)) return; // Unchanged
-		try {
-			controlConnection.setConf("ClientUseIPv4", enable ? "0" : "1");
-			controlConnection.setConf("ClientUseIPv6", enable ? "1" : "0");
-		} catch (TorNotRunningException e) {
-			throw new RuntimeException(e);
-		}
-	}
-
-	@ThreadSafe
-	protected static class PluginState {
-
-		private final MutableStateFlow<TorState> state =
-				MutableStateFlow(TorState.StartingStopping.INSTANCE);
-
-		@GuardedBy("this")
-		private boolean started = false,
-				stopped = false,
-				networkInitialised = false,
-				networkEnabled = false,
-				paddingEnabled = false,
-				ipv6Enabled = false,
-				circuitBuilt = false,
-				clockSkewed = false;
-		@GuardedBy("this")
-		private int bootstrapPercent = 0, numServiceUploads = 0;
-
-		@GuardedBy("this")
-		private int orConnectionsConnected = 0;
-
-		@GuardedBy("this")
-		private List<BridgeType> bridgeTypes = emptyList();
-
-		synchronized void setStarted() {
-			started = true;
-			state.setValue(getCurrentState());
-		}
-
-		@SuppressWarnings("BooleanMethodIsAlwaysInverted")
-		synchronized boolean isTorRunning() {
-			return started && !stopped;
-		}
-
-		synchronized void setStopped() {
-			stopped = true;
-			state.setValue(getCurrentState());
-		}
-
-		synchronized void setBootstrapPercent(int percent) {
-			if (percent < 0 || percent > 100) {
-				throw new IllegalArgumentException("percent: " + percent);
-			}
-			bootstrapPercent = percent;
-			if (percent == 100) clockSkewed = false;
-			state.setValue(getCurrentState());
-		}
-
-		synchronized void setClockSkewed() {
-			clockSkewed = true;
-			state.setValue(getCurrentState());
-		}
-
-		/**
-		 * Sets the `circuitBuilt` flag and returns true if the flag has
-		 * changed.
-		 */
-		private synchronized boolean setCircuitBuilt(boolean built) {
-			if (built == circuitBuilt) return false; // Unchanged
-			circuitBuilt = built;
-			if (bootstrapPercent == 100) clockSkewed = false;
-			state.setValue(getCurrentState());
-			return true; // Changed
-		}
-
-		synchronized void onServiceDescriptorUploaded() {
-			numServiceUploads++;
-			state.setValue(getCurrentState());
-		}
-
-		/**
-		 * Sets the `networkEnabled` flag and returns true if the flag has
-		 * changed.
-		 */
-		synchronized boolean enableNetwork(boolean enable) {
-			boolean wasInitialised = networkInitialised;
-			boolean wasEnabled = networkEnabled;
-			networkInitialised = true;
-			networkEnabled = enable;
-			if (!enable) circuitBuilt = false;
-			if (!wasInitialised || enable != wasEnabled) {
-				state.setValue(getCurrentState());
-			}
-			return enable != wasEnabled;
-		}
-
-		/**
-		 * Sets the `paddingEnabled` flag and returns true if the flag has
-		 * changed. Doesn't affect getState().
-		 */
-		private synchronized boolean enableConnectionPadding(boolean enable) {
-			if (enable == paddingEnabled) return false; // Unchanged
-			paddingEnabled = enable;
-			return true; // Changed
-		}
-
-		/**
-		 * Sets the `ipv6Enabled` flag and returns true if the flag has
-		 * changed. Doesn't affect getState().
-		 */
-		private synchronized boolean enableIpv6(boolean enable) {
-			if (enable == ipv6Enabled) return false; // Unchanged
-			ipv6Enabled = enable;
-			return true; // Changed
-		}
-
-		/**
-		 * Sets the list of bridge types being used and returns true if the
-		 * list has changed. The list is empty if bridges are disabled.
-		 * Doesn't affect getState().
-		 */
-		private synchronized boolean setBridgeTypes(List<BridgeType> types) {
-			if (types.equals(bridgeTypes)) return false; // Unchanged
-			bridgeTypes = types;
-			return true; // Changed
-		}
-
-		private synchronized TorState getCurrentState() {
-			if (!started || stopped) {
-				return TorState.StartingStopping.INSTANCE;
-			}
-			if (!networkInitialised) {
-				return new TorState.Enabling(bootstrapPercent);
-			}
-			if (!networkEnabled) return TorState.Inactive.INSTANCE;
-			if (clockSkewed) return TorState.ClockSkewed.INSTANCE;
-			if (bootstrapPercent == 100 && circuitBuilt &&
-					orConnectionsConnected > 0) {
-				return (numServiceUploads >= HS_DESC_UPLOADS) ?
-						TorState.Published.INSTANCE : TorState.Active.INSTANCE;
-			} else return new TorState.Enabling(bootstrapPercent);
-		}
-
-		private synchronized void onOrConnectionConnected() {
-			int oldConnected = orConnectionsConnected;
-			orConnectionsConnected++;
-			logOrConnections();
-			if (oldConnected == 0) state.setValue(getCurrentState());
-		}
+public interface TorPlugin extends Service {
 
-		private synchronized void onOrConnectionClosed() {
-			int oldConnected = orConnectionsConnected;
-			orConnectionsConnected--;
-			if (orConnectionsConnected < 0) {
-				LOG.warn("Count was zero before connection closed");
-				orConnectionsConnected = 0;
-			}
-			logOrConnections();
-			if (orConnectionsConnected == 0 && oldConnected != 0) {
-				state.setValue(getCurrentState());
-			}
-		}
+	StateFlow<TorState> getState();
 
-		@GuardedBy("this")
-		private void logOrConnections() {
-			info(LOG, () ->
-					orConnectionsConnected + " OR connections connected");
-		}
+	String getHiddenServiceAddress() throws DbException;
 
-	}
 }
diff --git a/mailbox-lib/src/main/java/org/briarproject/mailbox/core/tor/FakeTorPlugin.kt b/mailbox-lib/src/main/java/org/briarproject/mailbox/core/tor/FakeTorPlugin.kt
index e829930f035d4f072b034217db66a6f6fc329dfa..1a67a600e42883a4f337a49c0460aa12620e7b10 100644
--- a/mailbox-lib/src/main/java/org/briarproject/mailbox/core/tor/FakeTorPlugin.kt
+++ b/mailbox-lib/src/main/java/org/briarproject/mailbox/core/tor/FakeTorPlugin.kt
@@ -1,41 +1,24 @@
 package org.briarproject.mailbox.core.tor
 
-import org.briarproject.mailbox.core.lifecycle.IoExecutor
-import org.briarproject.mailbox.core.settings.SettingsManager
-import org.briarproject.mailbox.core.system.Clock
-import org.briarproject.mailbox.core.system.LocationUtils
-import org.briarproject.mailbox.core.system.ResourceProvider
-import java.io.ByteArrayInputStream
-import java.io.File
-import java.util.concurrent.Executor
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
 import javax.inject.Inject
 
-class FakeTorPlugin @Inject internal constructor(
-    @IoExecutor ioExecutor: Executor,
-    settingsManager: SettingsManager,
-    clock: Clock,
-    circumventionProvider: CircumventionProvider,
-) : TorPlugin(
-    ioExecutor,
-    settingsManager,
-    NetworkManager { NetworkStatus(false, false, false) },
-    LocationUtils { "US" },
-    clock,
-    ResourceProvider { _, _ -> ByteArrayInputStream(byteArrayOf(0x00)) },
-    circumventionProvider,
-    null,
-    File(""),
-) {
+class FakeTorPlugin @Inject constructor() : TorPlugin {
+
+    private val state = MutableStateFlow<TorState>(TorState.StartingStopping)
+
     override fun startService() {
-        state.setStarted()
-        state.enableNetwork(true)
-        circuitStatus("BUILT", "", "")
-        orConnStatus("CONNECTED", "")
-        state.setBootstrapPercent(100)
-        for (i in 1..5) state.onServiceDescriptorUploaded()
+        state.value = TorState.Published
+    }
+
+    override fun stopService() {
+        state.value = TorState.StartingStopping
+    }
+
+    override fun getState(): StateFlow<TorState> {
+        return state
     }
 
-    override fun stopService() {}
-    override fun getProcessId(): Int = 0
-    override fun getLastUpdateTime(): Long = Long.MAX_VALUE
+    override fun getHiddenServiceAddress(): String? = null
 }
diff --git a/mailbox-lib/src/main/java/org/briarproject/mailbox/core/tor/JavaTorPlugin.java b/mailbox-lib/src/main/java/org/briarproject/mailbox/core/tor/JavaTorPlugin.java
index 4053d359b8eecd9c4adf64d70f1db13e58234f55..72eef3529e9c8ab67d131e7a0600bc89a32ff5d5 100644
--- a/mailbox-lib/src/main/java/org/briarproject/mailbox/core/tor/JavaTorPlugin.java
+++ b/mailbox-lib/src/main/java/org/briarproject/mailbox/core/tor/JavaTorPlugin.java
@@ -35,7 +35,7 @@ import java.util.concurrent.Executor;
 
 import javax.annotation.Nullable;
 
-public class JavaTorPlugin extends TorPlugin {
+public class JavaTorPlugin extends AbstractTorPlugin {
 
 	JavaTorPlugin(Executor ioExecutor,
 			SettingsManager settingsManager,