diff --git a/mailbox-cli/src/main/java/org/briarproject/mailbox/core/tor/JavaCliNetworkManager.java b/mailbox-cli/src/main/java/org/briarproject/mailbox/core/tor/JavaCliNetworkManager.java
index 133055c3ec926b5941ca639d7214e3d4687c1c25..26d619d5c73679f494802f19ac6a127cd2d6a8af 100644
--- a/mailbox-cli/src/main/java/org/briarproject/mailbox/core/tor/JavaCliNetworkManager.java
+++ b/mailbox-cli/src/main/java/org/briarproject/mailbox/core/tor/JavaCliNetworkManager.java
@@ -16,31 +16,31 @@ import static org.slf4j.LoggerFactory.getLogger;
 
 class JavaCliNetworkManager implements NetworkManager {
 
-    private static final Logger LOG = getLogger(JavaCliNetworkManager.class);
-
-    @Inject
-    JavaCliNetworkManager() {
-    }
-
-    @Override
-    public NetworkStatus getNetworkStatus() {
-        boolean connected = false, hasIpv4 = false, hasIpv6Unicast = false;
-        try {
-            for (NetworkInterface i : getNetworkInterfaces()) {
-                if (i.isLoopback() || !i.isUp()) continue;
-                for (InetAddress addr : list(i.getInetAddresses())) {
-                    connected = true;
-                    if (addr instanceof Inet4Address) {
-                        hasIpv4 = true;
-                    } else if (!addr.isMulticastAddress()) {
-                        hasIpv6Unicast = true;
-                    }
-                }
-            }
-        } catch (SocketException e) {
-            logException(LOG, e);
-        }
-        return new NetworkStatus(connected, false, !hasIpv4 && hasIpv6Unicast);
-    }
+	private static final Logger LOG = getLogger(JavaCliNetworkManager.class);
+
+	@Inject
+	JavaCliNetworkManager() {
+	}
+
+	@Override
+	public NetworkStatus getNetworkStatus() {
+		boolean connected = false, hasIpv4 = false, hasIpv6Unicast = false;
+		try {
+			for (NetworkInterface i : getNetworkInterfaces()) {
+				if (i.isLoopback() || !i.isUp()) continue;
+				for (InetAddress addr : list(i.getInetAddresses())) {
+					connected = true;
+					if (addr instanceof Inet4Address) {
+						hasIpv4 = true;
+					} else if (!addr.isMulticastAddress()) {
+						hasIpv6Unicast = true;
+					}
+				}
+			}
+		} catch (SocketException e) {
+			logException(LOG, e);
+		}
+		return new NetworkStatus(connected, false, !hasIpv4 && hasIpv6Unicast);
+	}
 
 }
diff --git a/mailbox-cli/src/main/java/org/briarproject/mailbox/core/tor/JavaTorPlugin.java b/mailbox-cli/src/main/java/org/briarproject/mailbox/core/tor/JavaTorPlugin.java
index e097d26e7060728b0d692c4d6ea3de8001d4b7e0..6056de6a0c9796fdd81dc82c5345ab175d755afd 100644
--- a/mailbox-cli/src/main/java/org/briarproject/mailbox/core/tor/JavaTorPlugin.java
+++ b/mailbox-cli/src/main/java/org/briarproject/mailbox/core/tor/JavaTorPlugin.java
@@ -18,43 +18,44 @@ import javax.annotation.Nullable;
 
 public class JavaTorPlugin extends TorPlugin {
 
-    JavaTorPlugin(Executor ioExecutor,
-                  SettingsManager settingsManager,
-                  NetworkManager networkManager,
-                  LocationUtils locationUtils,
-                  Clock clock,
-                  ResourceProvider resourceProvider,
-                  CircumventionProvider circumventionProvider,
-                  Backoff backoff,
-                  @Nullable String architecture,
-                  File torDirectory) {
-        super(ioExecutor, settingsManager, networkManager, locationUtils, clock, resourceProvider,
-                circumventionProvider, backoff, architecture, torDirectory);
-    }
-
-    @Override
-    protected long getLastUpdateTime() {
-        CodeSource codeSource =
-                getClass().getProtectionDomain().getCodeSource();
-        if (codeSource == null) throw new AssertionError("CodeSource null");
-        try {
-            URI path = codeSource.getLocation().toURI();
-            File file = new File(path);
-            return file.lastModified();
-        } catch (URISyntaxException e) {
-            throw new AssertionError(e);
-        }
-    }
-
-    @Override
-    protected int getProcessId() {
-        return CLibrary.INSTANCE.getpid();
-    }
-
-    private interface CLibrary extends Library {
-
-        CLibrary INSTANCE = Native.load("c", CLibrary.class);
-
-        int getpid();
-    }
+	JavaTorPlugin(Executor ioExecutor,
+			SettingsManager settingsManager,
+			NetworkManager networkManager,
+			LocationUtils locationUtils,
+			Clock clock,
+			ResourceProvider resourceProvider,
+			CircumventionProvider circumventionProvider,
+			Backoff backoff,
+			@Nullable String architecture,
+			File torDirectory) {
+		super(ioExecutor, settingsManager, networkManager, locationUtils, clock,
+				resourceProvider,
+				circumventionProvider, backoff, architecture, torDirectory);
+	}
+
+	@Override
+	protected long getLastUpdateTime() {
+		CodeSource codeSource =
+				getClass().getProtectionDomain().getCodeSource();
+		if (codeSource == null) throw new AssertionError("CodeSource null");
+		try {
+			URI path = codeSource.getLocation().toURI();
+			File file = new File(path);
+			return file.lastModified();
+		} catch (URISyntaxException e) {
+			throw new AssertionError(e);
+		}
+	}
+
+	@Override
+	protected int getProcessId() {
+		return CLibrary.INSTANCE.getpid();
+	}
+
+	private interface CLibrary extends Library {
+
+		CLibrary INSTANCE = Native.load("c", CLibrary.class);
+
+		int getpid();
+	}
 }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/PoliteExecutor.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/PoliteExecutor.java
index 4149077b7c878a6c494b8e561db5f0421909b2c6..fc547da66694700999b4ffda1a0133b99b123982 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/PoliteExecutor.java
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/PoliteExecutor.java
@@ -36,7 +36,7 @@ public class PoliteExecutor implements Executor {
 	 * concurrently
 	 */
 	public PoliteExecutor(String tag, Executor delegate,
-                          int maxConcurrentTasks) {
+			int maxConcurrentTasks) {
 		this.delegate = delegate;
 		this.maxConcurrentTasks = maxConcurrentTasks;
 		log = Logger.getLogger(tag);
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/MigrationListener.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/MigrationListener.java
index d9283393f2dcbf5cc09db159546b19c47bf76fbb..05bb1d011a48e990b0454edbcec51ced37a50174 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/MigrationListener.java
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/MigrationListener.java
@@ -2,15 +2,15 @@ package org.briarproject.mailbox.core.db;
 
 public interface MigrationListener {
 
-    /**
-     * This is called when a migration is started while opening the database.
-     * It will be called once for each migration being applied.
-     */
-    void onDatabaseMigration();
+	/**
+	 * This is called when a migration is started while opening the database.
+	 * It will be called once for each migration being applied.
+	 */
+	void onDatabaseMigration();
 
-    /**
-     * This is called when compaction is started while opening the database.
-     */
-    void onDatabaseCompaction();
+	/**
+	 * This is called when compaction is started while opening the database.
+	 */
+	void onDatabaseCompaction();
 
 }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManager.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManager.java
index 2e8aa3ee71e0895b2cc056f5935972850c7482d0..03567871b02ea5362a2947bdf5ffd55f95e527f2 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManager.java
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManager.java
@@ -14,99 +14,99 @@ import kotlinx.coroutines.flow.StateFlow;
  */
 public interface LifecycleManager {
 
-    /**
-     * The result of calling {@link #startServices()}.
-     */
-    enum StartResult {
-        ALREADY_RUNNING,
-        SERVICE_ERROR,
-        SUCCESS
-    }
-
-    /**
-     * The state the lifecycle can be in.
-     * Returned by {@link #getLifecycleState()}
-     */
-    enum LifecycleState {
-
-        STOPPED,
-        STARTING,
-        MIGRATING_DATABASE,
-        COMPACTING_DATABASE,
-        STARTING_SERVICES,
-        RUNNING,
-        STOPPING;
-
-        public boolean isAfter(LifecycleState state) {
-            return ordinal() > state.ordinal();
-        }
-    }
-
-    /**
-     * Registers a hook to be called after the database is opened and before
-     * {@link Service services} are started. This method should be called
-     * before {@link #startServices()}.
-     */
-    void registerOpenDatabaseHook(OpenDatabaseHook hook);
-
-    /**
-     * Registers a {@link Service} to be started and stopped. This method
-     * should be called before {@link #startServices()}.
-     */
-    void registerService(Service s);
-
-    /**
-     * Registers an {@link ExecutorService} to be shut down. This method
-     * should be called before {@link #startServices()}.
-     */
-    void registerForShutdown(ExecutorService e);
-
-    /**
-     * Opens the {@link Database} using the given key and starts any
-     * registered {@link Service Services}.
-     */
-    @Wakeful
-    StartResult startServices();
-
-    /**
-     * Stops any registered {@link Service Services}, shuts down any
-     * registered {@link ExecutorService ExecutorServices}, and closes the
-     * {@link Database}.
-     */
-    @Wakeful
-    void stopServices();
-
-    /**
-     * Waits for the {@link Database} to be opened before returning.
-     */
-    void waitForDatabase() throws InterruptedException;
-
-    /**
-     * Waits for the {@link Database} to be opened and all registered
-     * {@link Service Services} to start before returning.
-     */
-    void waitForStartup() throws InterruptedException;
-
-    /**
-     * Waits for all registered {@link Service Services} to stop, all
-     * registered {@link ExecutorService ExecutorServices} to shut down, and
-     * the {@link Database} to be closed before returning.
-     */
-    void waitForShutdown() throws InterruptedException;
-
-    /**
-     * Returns the current state of the lifecycle.
-     */
-    LifecycleState getLifecycleState();
-
-    StateFlow<LifecycleState> getLifecycleStateFlow();
-
-    interface OpenDatabaseHook {
-        /**
-         * Called when the database is being opened, before
-         * {@link #waitForDatabase()} returns.
-         */
-        @Wakeful
-        void onDatabaseOpened();
-    }
+	/**
+	 * The result of calling {@link #startServices()}.
+	 */
+	enum StartResult {
+		ALREADY_RUNNING,
+		SERVICE_ERROR,
+		SUCCESS
+	}
+
+	/**
+	 * The state the lifecycle can be in.
+	 * Returned by {@link #getLifecycleState()}
+	 */
+	enum LifecycleState {
+
+		STOPPED,
+		STARTING,
+		MIGRATING_DATABASE,
+		COMPACTING_DATABASE,
+		STARTING_SERVICES,
+		RUNNING,
+		STOPPING;
+
+		public boolean isAfter(LifecycleState state) {
+			return ordinal() > state.ordinal();
+		}
+	}
+
+	/**
+	 * Registers a hook to be called after the database is opened and before
+	 * {@link Service services} are started. This method should be called
+	 * before {@link #startServices()}.
+	 */
+	void registerOpenDatabaseHook(OpenDatabaseHook hook);
+
+	/**
+	 * Registers a {@link Service} to be started and stopped. This method
+	 * should be called before {@link #startServices()}.
+	 */
+	void registerService(Service s);
+
+	/**
+	 * Registers an {@link ExecutorService} to be shut down. This method
+	 * should be called before {@link #startServices()}.
+	 */
+	void registerForShutdown(ExecutorService e);
+
+	/**
+	 * Opens the {@link Database} using the given key and starts any
+	 * registered {@link Service Services}.
+	 */
+	@Wakeful
+	StartResult startServices();
+
+	/**
+	 * Stops any registered {@link Service Services}, shuts down any
+	 * registered {@link ExecutorService ExecutorServices}, and closes the
+	 * {@link Database}.
+	 */
+	@Wakeful
+	void stopServices();
+
+	/**
+	 * Waits for the {@link Database} to be opened before returning.
+	 */
+	void waitForDatabase() throws InterruptedException;
+
+	/**
+	 * Waits for the {@link Database} to be opened and all registered
+	 * {@link Service Services} to start before returning.
+	 */
+	void waitForStartup() throws InterruptedException;
+
+	/**
+	 * Waits for all registered {@link Service Services} to stop, all
+	 * registered {@link ExecutorService ExecutorServices} to shut down, and
+	 * the {@link Database} to be closed before returning.
+	 */
+	void waitForShutdown() throws InterruptedException;
+
+	/**
+	 * Returns the current state of the lifecycle.
+	 */
+	LifecycleState getLifecycleState();
+
+	StateFlow<LifecycleState> getLifecycleStateFlow();
+
+	interface OpenDatabaseHook {
+		/**
+		 * Called when the database is being opened, before
+		 * {@link #waitForDatabase()} returns.
+		 */
+		@Wakeful
+		void onDatabaseOpened();
+	}
 }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/ServiceException.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/ServiceException.java
index fbd05bb6a4323e9c7cbc5f5057b465b0601e2dc3..fae8cd2ccfb34d28dc831b7d736d47f16b3952d4 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/ServiceException.java
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/ServiceException.java
@@ -5,15 +5,15 @@ package org.briarproject.mailbox.core.lifecycle;
  */
 public class ServiceException extends Exception {
 
-    public ServiceException() {
-        super();
-    }
+	public ServiceException() {
+		super();
+	}
 
-    public ServiceException(String msg) {
-        super(msg);
-    }
+	public ServiceException(String msg) {
+		super(msg);
+	}
 
-    public ServiceException(Throwable cause) {
-        super(cause);
-    }
+	public ServiceException(Throwable cause) {
+		super(cause);
+	}
 }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/Clock.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/Clock.java
index 2ed21d9e891f0526ddc496e3514e37c767bc09d8..c3d8f0e3959ec6a496f8ae6a8d30c3f72bbec94b 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/Clock.java
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/Clock.java
@@ -1,5 +1,5 @@
 package org.briarproject.mailbox.core.system;
 
 public interface Clock {
-    long currentTimeMillis();
+	long currentTimeMillis();
 }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/ResourceProvider.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/ResourceProvider.java
index 70ec10cd411deeb6dad0c511e7b96cc834502f44..8a3d0387460799adba97776bad4523ba490702ad 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/ResourceProvider.java
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/ResourceProvider.java
@@ -4,5 +4,5 @@ import java.io.InputStream;
 
 public interface ResourceProvider {
 
-    InputStream getResourceInputStream(String name, String extension);
+	InputStream getResourceInputStream(String name, String extension);
 }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/CircumventionProvider.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/CircumventionProvider.java
index f1d33614cc082a19f409921d57e196130f74af68..ff6a32100449e9e054cad315828f9c61dc6f6968 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/CircumventionProvider.java
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/CircumventionProvider.java
@@ -6,7 +6,7 @@ public interface CircumventionProvider {
 
 	/**
 	 * Countries where Tor is blocked, i.e. vanilla Tor connection won't work.
-	 *
+	 * <p>
 	 * See https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
 	 * and https://trac.torproject.org/projects/tor/wiki/doc/OONI/censorshipwiki
 	 */
@@ -16,7 +16,7 @@ public interface CircumventionProvider {
 	 * Countries where obfs4 or meek bridge connections are likely to work.
 	 * Should be a subset of {@link #BLOCKED}.
 	 */
-	String[] BRIDGES = { "CN", "IR", "EG", "BY", "TR", "SY", "VE" };
+	String[] BRIDGES = {"CN", "IR", "EG", "BY", "TR", "SY", "VE"};
 
 	/**
 	 * Countries where obfs4 bridges won't work and meek is needed.
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 276a592e30e1569c757afc60e696b5c14128ca83..59b63c9e8c92e1c089b1aa25453225591f7683ef 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
@@ -64,605 +64,607 @@ 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", "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;
-
-    private final Executor ioExecutor;
-    private final Executor connectionStatusExecutor;
-    private final SettingsManager settingsManager;
-    private final NetworkManager networkManager;
-    private final LocationUtils locationUtils;
-    private final Clock clock;
-    private final Backoff backoff;
-    @Nullable
-    private final String architecture;
-    private final CircumventionProvider circumventionProvider;
-    private final ResourceProvider resourceProvider;
-    private final File torDirectory, geoIpFile, 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,
-              Backoff backoff,
-              @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.backoff = backoff;
-        this.architecture = architecture;
-        this.torDirectory = torDirectory;
-        geoIpFile = new File(torDirectory, "geoip");
-        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");
-    }
-
-    @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();
-            }
-        }
-        // Install or update the assets if necessary
-        if (!assetsAreUpToDate()) installAssets();
-        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);
-        try {
-            torProcess = pb.start();
-        } catch (SecurityException | IOException e) {
-            throw new ServiceException(e);
-        }
-        // Log the process's standard output until it detaches
-        if (LOG.isInfoEnabled()) {
-            Scanner stdout = new Scanner(torProcess.getInputStream());
-            Scanner stderr = new Scanner(torProcess.getErrorStream());
-            while (stdout.hasNextLine() || stderr.hasNextLine()) {
-                if (stdout.hasNextLine()) {
-                    LOG.info(stdout.nextLine());
-                }
-                if (stderr.hasNextLine()) {
-                    LOG.info(stderr.nextLine());
-                }
-            }
-            stdout.close();
-            stderr.close();
-        }
-        try {
-            // 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();
-            }
-            // 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 phase = controlConnection.getInfo("status/bootstrap-phase");
-            if (phase != null && phase.contains("PROGRESS=100")) {
-                LOG.info("Tor has already bootstrapped");
-                state.setBootstrapped();
-            }
-        } 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();
-            extract(getGeoIpInputStream(), geoIpFile);
-            extract(getConfigInputStream(), configFile);
-            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 getGeoIpInputStream() throws IOException {
-        InputStream in = resourceProvider.getResourceInputStream("geoip",
-                ".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 InputStream getConfigInputStream() {
-        ClassLoader cl = getClass().getClassLoader();
-        return requireNonNull(cl.getResourceAsStream("torrc"));
-    }
-
-    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);
-        }
-    }
-
-    @IoExecutor
-    private void publishHiddenService(String port) {
-        if (!state.isTorRunning()) return;
-
-        Settings s;
-        try {
-            s = settingsManager.getSettings(SETTINGS_NAMESPACE);
-        } catch (DbException e) {
-            logException(LOG, e);
-            s = new Settings();
-        }
-        String privateKey3 = s.get(HS_PRIVATE_KEY_V3);
-        publishV3HiddenService(port, privateKey3);
-    }
-
-    @IoExecutor
-    private void publishV3HiddenService(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 (IOException e) {
-            logException(LOG, e);
-            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));
-
-        // TODO remove before release
-        LOG.warn("V3 hidden service: http://" + onion3 + ".onion");
-
-        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);
-            }
-        }
-    }
-
-    @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 {
-        state.enableNetwork(enable);
-        controlConnection.setConf("DisableNetwork", enable ? "0" : "1");
-    }
-
-    private void enableBridges(boolean enable, boolean needsMeek)
-            throws IOException {
-        if (enable) {
-            Collection<String> conf = new ArrayList<>();
-            conf.add("UseBridges 1");
-            File obfs4File = getObfs4ExecutableFile();
-            if (needsMeek) {
-                conf.add("ClientTransportPlugin meek_lite exec " +
-                        obfs4File.getAbsolutePath());
-            } else {
-                conf.add("ClientTransportPlugin obfs4 exec " +
-                        obfs4File.getAbsolutePath());
-            }
-            conf.addAll(circumventionProvider.getBridges(needsMeek));
-            controlConnection.setConf(conf);
-        } else {
-            controlConnection.setConf("UseBridges", "0");
-        }
-    }
-
-    @Override
-    public void stopService() {
-        ServerSocket ss = state.setStopped();
-        tryToClose(ss, LOG);
-        if (controlSocket != null && controlConnection != null) {
-            try {
-                LOG.info("Stopping Tor");
-                controlConnection.setConf("DisableNetwork", "1");
-                controlConnection.shutdownTor("TERM");
-                controlSocket.close();
-            } catch (IOException e) {
-                logException(LOG, e);
-            }
-        }
-    }
-
-    @Override
-    public void circuitStatus(String status, String id, String path) {
-        if (status.equals("BUILT") &&
-                state.getAndSetCircuitBuilt()) {
-            LOG.info("First circuit built");
-            backoff.reset();
-        }
-    }
-
-    @Override
-    public void streamStatus(String status, String id, String target) {
-    }
-
-    @Override
-    public void orConnStatus(String status, String orName) {
-        info(LOG, () -> "OR connection " + status + " " + orName);
-        if (status.equals("CLOSED") || status.equals("FAILED")) {
-            // Check whether we've lost connectivity
-            updateConnectionStatus(networkManager.getNetworkStatus());
-        }
-    }
-
-    @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") && msg.startsWith("Bootstrapped 100%")) {
-            state.setBootstrapped();
-            backoff.reset();
-        }
-    }
-
-    @Override
-    public void unrecognized(String type, String msg) {
-        if (type.equals("HS_DESC") && msg.startsWith("UPLOADED")) {
-            LOG.info("V3 descriptor uploaded");
-        }
-    }
-
-    @Override
-    public void eventOccurred(Event e) {
-        if (e instanceof NetworkStatusEvent) {
-            updateConnectionStatus(((NetworkStatusEvent) e).getStatus());
-        }
-    }
-
-    private void disableNetwork() {
-        connectionStatusExecutor.execute(() -> {
-            try {
-                if (state.isTorRunning()) enableNetwork(false);
-            } catch (IOException ex) {
-                logException(LOG, ex);
-            }
-        });
-    }
-
-    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 blocked =
-                    circumventionProvider.isTorProbablyBlocked(country);
-            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);
-            }
-
-            int reasonsDisabled = 0;
-            boolean enableNetwork = false;
-            boolean enableBridges = false;
-            boolean useMeek = false;
-
-            if (!online) {
-                LOG.info("Disabling network, device is offline");
-            } else {
-                LOG.info("Enabling network");
-                enableNetwork = true;
-                if (blocked && bridgesWork) {
-                    if (ipv6Only || circumventionProvider.needsMeek(country)) {
-                        LOG.info("Using meek bridges");
-                        enableBridges = true;
-                        useMeek = true;
-                    } else {
-                        LOG.info("Using obfs4 bridges");
-                        enableBridges = true;
-                    }
-                } else {
-                    LOG.info("Not using bridges");
-                }
-            }
-            state.setReasonsDisabled(reasonsDisabled);
-            try {
-                if (enableNetwork) {
-                    enableBridges(enableBridges, useMeek);
-                    enableConnectionPadding(true);
-                    useIpv6(ipv6Only);
-                }
-                enableNetwork(enableNetwork);
-            } catch (IOException e) {
-                logException(LOG, e);
-            }
-        });
-    }
-
-    private void enableConnectionPadding(boolean enable) throws IOException {
-        controlConnection.setConf("ConnectionPadding", enable ? "1" : "0");
-    }
-
-    private void useIpv6(boolean ipv6Only) throws IOException {
-        controlConnection.setConf("ClientUseIPv4", ipv6Only ? "0" : "1");
-        controlConnection.setConf("ClientUseIPv6", ipv6Only ? "1" : "0");
-    }
-
-    @ThreadSafe
-    protected class PluginState {
-
-        @GuardedBy("this")
-        private boolean started = false,
-                stopped = false,
-                networkInitialised = false,
-                networkEnabled = false,
-                bootstrapped = false,
-                circuitBuilt = false,
-                settingsChecked = false;
-
-        @GuardedBy("this")
-        private int reasonsDisabled = 0;
-
-        @GuardedBy("this")
-        @Nullable
-        private ServerSocket serverSocket = null;
-
-        synchronized void setStarted() {
-            started = true;
+		implements Service, EventHandler, EventListener {
+
+	private static final Logger LOG = getLogger(TorPlugin.class);
+
+	private static final String[] EVENTS = {
+			"CIRC", "ORCONN", "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;
+
+	private final Executor ioExecutor;
+	private final Executor connectionStatusExecutor;
+	private final SettingsManager settingsManager;
+	private final NetworkManager networkManager;
+	private final LocationUtils locationUtils;
+	private final Clock clock;
+	private final Backoff backoff;
+	@Nullable
+	private final String architecture;
+	private final CircumventionProvider circumventionProvider;
+	private final ResourceProvider resourceProvider;
+	private final File torDirectory, geoIpFile, 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,
+			Backoff backoff,
+			@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.backoff = backoff;
+		this.architecture = architecture;
+		this.torDirectory = torDirectory;
+		geoIpFile = new File(torDirectory, "geoip");
+		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");
+	}
+
+	@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();
+			}
+		}
+		// Install or update the assets if necessary
+		if (!assetsAreUpToDate()) installAssets();
+		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);
+		try {
+			torProcess = pb.start();
+		} catch (SecurityException | IOException e) {
+			throw new ServiceException(e);
+		}
+		// Log the process's standard output until it detaches
+		if (LOG.isInfoEnabled()) {
+			Scanner stdout = new Scanner(torProcess.getInputStream());
+			Scanner stderr = new Scanner(torProcess.getErrorStream());
+			while (stdout.hasNextLine() || stderr.hasNextLine()) {
+				if (stdout.hasNextLine()) {
+					LOG.info(stdout.nextLine());
+				}
+				if (stderr.hasNextLine()) {
+					LOG.info(stderr.nextLine());
+				}
+			}
+			stdout.close();
+			stderr.close();
+		}
+		try {
+			// 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();
+			}
+			// 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 phase = controlConnection.getInfo("status/bootstrap-phase");
+			if (phase != null && phase.contains("PROGRESS=100")) {
+				LOG.info("Tor has already bootstrapped");
+				state.setBootstrapped();
+			}
+		} 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();
+			extract(getGeoIpInputStream(), geoIpFile);
+			extract(getConfigInputStream(), configFile);
+			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 getGeoIpInputStream() throws IOException {
+		InputStream in = resourceProvider.getResourceInputStream("geoip",
+				".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 InputStream getConfigInputStream() {
+		ClassLoader cl = getClass().getClassLoader();
+		return requireNonNull(cl.getResourceAsStream("torrc"));
+	}
+
+	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);
+		}
+	}
+
+	@IoExecutor
+	private void publishHiddenService(String port) {
+		if (!state.isTorRunning()) return;
+
+		Settings s;
+		try {
+			s = settingsManager.getSettings(SETTINGS_NAMESPACE);
+		} catch (DbException e) {
+			logException(LOG, e);
+			s = new Settings();
+		}
+		String privateKey3 = s.get(HS_PRIVATE_KEY_V3);
+		publishV3HiddenService(port, privateKey3);
+	}
+
+	@IoExecutor
+	private void publishV3HiddenService(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 (IOException e) {
+			logException(LOG, e);
+			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));
+
+		// TODO remove before release
+		LOG.warn("V3 hidden service: http://" + onion3 + ".onion");
+
+		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);
+			}
+		}
+	}
+
+	@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 {
+		state.enableNetwork(enable);
+		controlConnection.setConf("DisableNetwork", enable ? "0" : "1");
+	}
+
+	private void enableBridges(boolean enable, boolean needsMeek)
+			throws IOException {
+		if (enable) {
+			Collection<String> conf = new ArrayList<>();
+			conf.add("UseBridges 1");
+			File obfs4File = getObfs4ExecutableFile();
+			if (needsMeek) {
+				conf.add("ClientTransportPlugin meek_lite exec " +
+						obfs4File.getAbsolutePath());
+			} else {
+				conf.add("ClientTransportPlugin obfs4 exec " +
+						obfs4File.getAbsolutePath());
+			}
+			conf.addAll(circumventionProvider.getBridges(needsMeek));
+			controlConnection.setConf(conf);
+		} else {
+			controlConnection.setConf("UseBridges", "0");
+		}
+	}
+
+	@Override
+	public void stopService() {
+		ServerSocket ss = state.setStopped();
+		tryToClose(ss, LOG);
+		if (controlSocket != null && controlConnection != null) {
+			try {
+				LOG.info("Stopping Tor");
+				controlConnection.setConf("DisableNetwork", "1");
+				controlConnection.shutdownTor("TERM");
+				controlSocket.close();
+			} catch (IOException e) {
+				logException(LOG, e);
+			}
+		}
+	}
+
+	@Override
+	public void circuitStatus(String status, String id, String path) {
+		if (status.equals("BUILT") &&
+				state.getAndSetCircuitBuilt()) {
+			LOG.info("First circuit built");
+			backoff.reset();
+		}
+	}
+
+	@Override
+	public void streamStatus(String status, String id, String target) {
+	}
+
+	@Override
+	public void orConnStatus(String status, String orName) {
+		info(LOG, () -> "OR connection " + status + " " + orName);
+		if (status.equals("CLOSED") || status.equals("FAILED")) {
+			// Check whether we've lost connectivity
+			updateConnectionStatus(networkManager.getNetworkStatus());
+		}
+	}
+
+	@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") && msg.startsWith("Bootstrapped 100%")) {
+			state.setBootstrapped();
+			backoff.reset();
+		}
+	}
+
+	@Override
+	public void unrecognized(String type, String msg) {
+		if (type.equals("HS_DESC") && msg.startsWith("UPLOADED")) {
+			LOG.info("V3 descriptor uploaded");
+		}
+	}
+
+	@Override
+	public void eventOccurred(Event e) {
+		if (e instanceof NetworkStatusEvent) {
+			updateConnectionStatus(((NetworkStatusEvent) e).getStatus());
+		}
+	}
+
+	private void disableNetwork() {
+		connectionStatusExecutor.execute(() -> {
+			try {
+				if (state.isTorRunning()) enableNetwork(false);
+			} catch (IOException ex) {
+				logException(LOG, ex);
+			}
+		});
+	}
+
+	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 blocked =
+					circumventionProvider.isTorProbablyBlocked(country);
+			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);
+			}
+
+			int reasonsDisabled = 0;
+			boolean enableNetwork = false;
+			boolean enableBridges = false;
+			boolean useMeek = false;
+
+			if (!online) {
+				LOG.info("Disabling network, device is offline");
+			} else {
+				LOG.info("Enabling network");
+				enableNetwork = true;
+				if (blocked && bridgesWork) {
+					if (ipv6Only || circumventionProvider.needsMeek(country)) {
+						LOG.info("Using meek bridges");
+						enableBridges = true;
+						useMeek = true;
+					} else {
+						LOG.info("Using obfs4 bridges");
+						enableBridges = true;
+					}
+				} else {
+					LOG.info("Not using bridges");
+				}
+			}
+			state.setReasonsDisabled(reasonsDisabled);
+			try {
+				if (enableNetwork) {
+					enableBridges(enableBridges, useMeek);
+					enableConnectionPadding(true);
+					useIpv6(ipv6Only);
+				}
+				enableNetwork(enableNetwork);
+			} catch (IOException e) {
+				logException(LOG, e);
+			}
+		});
+	}
+
+	private void enableConnectionPadding(boolean enable) throws IOException {
+		controlConnection.setConf("ConnectionPadding", enable ? "1" : "0");
+	}
+
+	private void useIpv6(boolean ipv6Only) throws IOException {
+		controlConnection.setConf("ClientUseIPv4", ipv6Only ? "0" : "1");
+		controlConnection.setConf("ClientUseIPv6", ipv6Only ? "1" : "0");
+	}
+
+	@ThreadSafe
+	protected class PluginState {
+
+		@GuardedBy("this")
+		private boolean started = false,
+				stopped = false,
+				networkInitialised = false,
+				networkEnabled = false,
+				bootstrapped = false,
+				circuitBuilt = false,
+				settingsChecked = false;
+
+		@GuardedBy("this")
+		private int reasonsDisabled = 0;
+
+		@GuardedBy("this")
+		@Nullable
+		private ServerSocket serverSocket = null;
+
+		synchronized void setStarted() {
+			started = true;
 //            callback.pluginStateChanged(getState());
-        }
+		}
 
-        synchronized boolean isTorRunning() {
-            return started && !stopped;
-        }
+		synchronized boolean isTorRunning() {
+			return started && !stopped;
+		}
 
-        @Nullable
-        synchronized ServerSocket setStopped() {
-            stopped = true;
-            ServerSocket ss = serverSocket;
-            serverSocket = null;
+		@Nullable
+		synchronized ServerSocket setStopped() {
+			stopped = true;
+			ServerSocket ss = serverSocket;
+			serverSocket = null;
 //            callback.pluginStateChanged(getState());
-            return ss;
-        }
+			return ss;
+		}
 
-        synchronized void setBootstrapped() {
-            bootstrapped = true;
+		synchronized void setBootstrapped() {
+			bootstrapped = true;
 //            callback.pluginStateChanged(getState());
-        }
+		}
 
-        synchronized boolean getAndSetCircuitBuilt() {
-            boolean firstCircuit = !circuitBuilt;
-            circuitBuilt = true;
+		synchronized boolean getAndSetCircuitBuilt() {
+			boolean firstCircuit = !circuitBuilt;
+			circuitBuilt = true;
 //            callback.pluginStateChanged(getState());
-            return firstCircuit;
-        }
+			return firstCircuit;
+		}
 
-        synchronized void enableNetwork(boolean enable) {
-            networkInitialised = true;
-            networkEnabled = enable;
-            if (!enable) circuitBuilt = false;
+		synchronized void enableNetwork(boolean enable) {
+			networkInitialised = true;
+			networkEnabled = enable;
+			if (!enable) circuitBuilt = false;
 //            callback.pluginStateChanged(getState());
-        }
+		}
 
-        synchronized void setReasonsDisabled(int reasonsDisabled) {
-            settingsChecked = true;
-            this.reasonsDisabled = reasonsDisabled;
+		synchronized void setReasonsDisabled(int reasonsDisabled) {
+			settingsChecked = true;
+			this.reasonsDisabled = reasonsDisabled;
 //            callback.pluginStateChanged(getState());
-        }
-
-        synchronized State getState() {
-            if (!started || stopped || !settingsChecked) {
-                return STARTING_STOPPING;
-            }
-            if (reasonsDisabled != 0) return DISABLED;
-            if (!networkInitialised) return ENABLING;
-            if (!networkEnabled) return INACTIVE;
-            return bootstrapped && circuitBuilt ? ACTIVE : ENABLING;
-        }
-
-        synchronized int getReasonsDisabled() {
-            return getState() == DISABLED ? reasonsDisabled : 0;
-        }
-
-    }
-
-    enum State {
-
-        /**
-         * The plugin has not finished starting or has been stopped.
-         */
-        STARTING_STOPPING,
-
-        /**
-         * The plugin is disabled by settings.
-         */
-        DISABLED,
-
-        /**
-         * The plugin is being enabled and can't yet make or receive
-         * connections.
-         */
-        ENABLING,
-
-        /**
-         * The plugin is enabled and can make or receive connections.
-         */
-        ACTIVE,
-
-        /**
-         * The plugin is enabled but can't make or receive connections
-         */
-        INACTIVE
-    }
+		}
+
+		synchronized State getState() {
+			if (!started || stopped || !settingsChecked) {
+				return STARTING_STOPPING;
+			}
+			if (reasonsDisabled != 0) return DISABLED;
+			if (!networkInitialised) return ENABLING;
+			if (!networkEnabled) return INACTIVE;
+			return bootstrapped && circuitBuilt ? ACTIVE : ENABLING;
+		}
+
+		synchronized int getReasonsDisabled() {
+			return getState() == DISABLED ? reasonsDisabled : 0;
+		}
+
+	}
+
+	enum State {
+
+		/**
+		 * The plugin has not finished starting or has been stopped.
+		 */
+		STARTING_STOPPING,
+
+		/**
+		 * The plugin is disabled by settings.
+		 */
+		DISABLED,
+
+		/**
+		 * The plugin is being enabled and can't yet make or receive
+		 * connections.
+		 */
+		ENABLING,
+
+		/**
+		 * The plugin is enabled and can make or receive connections.
+		 */
+		ACTIVE,
+
+		/**
+		 * The plugin is enabled but can't make or receive connections
+		 */
+		INACTIVE
+	}
 }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/IoUtils.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/IoUtils.java
index f0f70a70ecb71355a14a69833ded9f33c6e395af..b6cd2117c141babd94ef53fa5ca789a05c486284 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/IoUtils.java
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/IoUtils.java
@@ -19,99 +19,101 @@ import static org.slf4j.LoggerFactory.getLogger;
 
 public class IoUtils {
 
-    private static final Logger LOG = getLogger(IoUtils.class);
-
-    public static void deleteFileOrDir(File f) {
-        if (f.isFile()) {
-            delete(f);
-        } else if (f.isDirectory()) {
-            File[] children = f.listFiles();
-            if (children == null) {
-                warn(LOG, () -> "Could not list files in " + f.getAbsolutePath());
-            } else {
-                for (File child : children) deleteFileOrDir(child);
-            }
-            delete(f);
-        }
-    }
-
-    private static void delete(File f) {
-        if (!f.delete()) warn(LOG, () -> "Could not delete " + f.getAbsolutePath());
-    }
-
-    public static void copyAndClose(InputStream in, OutputStream out) {
-        byte[] buf = new byte[4096];
-        try {
-            while (true) {
-                int read = in.read(buf);
-                if (read == -1) break;
-                out.write(buf, 0, read);
-            }
-            in.close();
-            out.flush();
-            out.close();
-        } catch (IOException e) {
-            tryToClose(in, LOG);
-            tryToClose(out, LOG);
-        }
-    }
-
-    public static void tryToClose(@Nullable Closeable c, Logger logger) {
-        try {
-            if (c != null) c.close();
-        } catch (IOException e) {
-            logException(logger, e);
-        }
-    }
-
-    public static void tryToClose(@Nullable Socket s, Logger logger) {
-        try {
-            if (s != null) s.close();
-        } catch (IOException e) {
-            logException(logger, e);
-        }
-    }
-
-    public static void tryToClose(@Nullable ServerSocket ss, Logger logger) {
-        try {
-            if (ss != null) ss.close();
-        } catch (IOException e) {
-            logException(logger, e);
-        }
-    }
-
-    public static void read(InputStream in, byte[] b) throws IOException {
-        int offset = 0;
-        while (offset < b.length) {
-            int read = in.read(b, offset, b.length - offset);
-            if (read == -1) throw new EOFException();
-            offset += read;
-        }
-    }
-
-    // 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 boolean isNonEmptyDirectory(File f) {
-        if (!f.isDirectory()) return false;
-        File[] children = f.listFiles();
-        return children != null && children.length > 0;
-    }
+	private static final Logger LOG = getLogger(IoUtils.class);
+
+	public static void deleteFileOrDir(File f) {
+		if (f.isFile()) {
+			delete(f);
+		} else if (f.isDirectory()) {
+			File[] children = f.listFiles();
+			if (children == null) {
+				warn(LOG,
+						() -> "Could not list files in " + f.getAbsolutePath());
+			} else {
+				for (File child : children) deleteFileOrDir(child);
+			}
+			delete(f);
+		}
+	}
+
+	private static void delete(File f) {
+		if (!f.delete())
+			warn(LOG, () -> "Could not delete " + f.getAbsolutePath());
+	}
+
+	public static void copyAndClose(InputStream in, OutputStream out) {
+		byte[] buf = new byte[4096];
+		try {
+			while (true) {
+				int read = in.read(buf);
+				if (read == -1) break;
+				out.write(buf, 0, read);
+			}
+			in.close();
+			out.flush();
+			out.close();
+		} catch (IOException e) {
+			tryToClose(in, LOG);
+			tryToClose(out, LOG);
+		}
+	}
+
+	public static void tryToClose(@Nullable Closeable c, Logger logger) {
+		try {
+			if (c != null) c.close();
+		} catch (IOException e) {
+			logException(logger, e);
+		}
+	}
+
+	public static void tryToClose(@Nullable Socket s, Logger logger) {
+		try {
+			if (s != null) s.close();
+		} catch (IOException e) {
+			logException(logger, e);
+		}
+	}
+
+	public static void tryToClose(@Nullable ServerSocket ss, Logger logger) {
+		try {
+			if (ss != null) ss.close();
+		} catch (IOException e) {
+			logException(logger, e);
+		}
+	}
+
+	public static void read(InputStream in, byte[] b) throws IOException {
+		int offset = 0;
+		while (offset < b.length) {
+			int read = in.read(b, offset, b.length - offset);
+			if (read == -1) throw new EOFException();
+			offset += read;
+		}
+	}
+
+	// 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 boolean isNonEmptyDirectory(File f) {
+		if (!f.isDirectory()) return false;
+		File[] children = f.listFiles();
+		return children != null && children.length > 0;
+	}
 }