diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index f6631329795d70f75db972aac4a1f206dfdaf754..69c6325f0522458a57a0e6c7e55cb3d4a1130f87 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -6,6 +6,7 @@
       </option>
       <option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
       <option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
+      <option name="ALLOW_TRAILING_COMMA" value="true" />
       <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
     </JetCodeStyleSettings>
     <codeStyleSettings language="XML">
diff --git a/build.gradle b/build.gradle
index 5dbb31c8e66191bebfac5b0d44c3718d24a7a242..20a8ec64acc71ca227ade83c21a2017294b49595 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,6 +1,8 @@
 buildscript {
     ext.kotlin_version = '1.5.21'
     ext.hilt_version = '2.38.1'
+    ext.tor_version = '0.3.5.15'
+    ext.obfs4_version = '0.0.12-dev-40245c4a'
     ext.junit_version = '5.7.2'
     ext.mockk_version = '1.10.4'
     repositories {
@@ -17,6 +19,7 @@ buildscript {
 allprojects {
     repositories {
         google()
+        jcenter() // for dependencies that haven't moved, yet
         mavenCentral()
     }
 }
diff --git a/mailbox-android/.gitignore b/mailbox-android/.gitignore
index 42afabfd2abebf31384ca7797186a27a4b7dbee8..baba0a48b70154e90f4e94917ef00b07632d174f 100644
--- a/mailbox-android/.gitignore
+++ b/mailbox-android/.gitignore
@@ -1 +1,3 @@
-/build
\ No newline at end of file
+/build
+/src/main/res/raw/geoip.zip
+/src/main/jniLibs/*/lib*.so
diff --git a/mailbox-android/build.gradle b/mailbox-android/build.gradle
index ca37ef77ee00e0df0f576a3c135870bbacec7c88..3825d95d14ac73864a93d231a0628eae1880ce70 100644
--- a/mailbox-android/build.gradle
+++ b/mailbox-android/build.gradle
@@ -1,3 +1,5 @@
+import com.android.build.gradle.tasks.MergeResources
+
 plugins {
     id 'com.android.application'
     id 'kotlin-android'
@@ -43,6 +45,10 @@ android {
     }
 }
 
+configurations {
+    tor
+}
+
 dependencies {
     implementation project(path: ':mailbox-core', configuration: 'default')
 
@@ -66,10 +72,71 @@ dependencies {
     def multidex_version = "2.0.1"
     implementation "androidx.multidex:multidex:$multidex_version"
 
+    tor "org.briarproject:tor-android:$tor_version"
+    tor "org.briarproject:obfs4proxy-android:$obfs4_version@zip"
+
     testImplementation 'junit:junit:4.13.2'
 
     androidTestImplementation 'androidx.test.ext:junit:1.1.3'
     androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
 }
 
+def torBinariesDir = 'src/main/res/raw'
+def torLibsDir = 'src/main/jniLibs'
+
+task cleanTorBinaries {
+    doLast {
+        delete fileTree(torBinariesDir) { include '*.zip' }
+        delete fileTree(torLibsDir) { include '**/*.so' }
+    }
+}
+
+clean.dependsOn cleanTorBinaries
+
+task unpackTorBinaries {
+    doLast {
+        copy {
+            from configurations.tor.collect { zipTree(it) }
+            into torBinariesDir
+            include 'geoip.zip'
+        }
+        configurations.tor.each { outer ->
+            zipTree(outer).each { inner ->
+                if (inner.name.endsWith('_arm_pie.zip')) {
+                    copy {
+                        from zipTree(inner)
+                        into torLibsDir
+                        rename '(.*)', 'armeabi-v7a/lib$1.so'
+                    }
+                } else if (inner.name.endsWith('_arm64_pie.zip')) {
+                    copy {
+                        from zipTree(inner)
+                        into torLibsDir
+                        rename '(.*)', 'arm64-v8a/lib$1.so'
+                    }
+                } else if (inner.name.endsWith('_x86_pie.zip')) {
+                    copy {
+                        from zipTree(inner)
+                        into torLibsDir
+                        rename '(.*)', 'x86/lib$1.so'
+                    }
+                } else if (inner.name.endsWith('_x86_64_pie.zip')) {
+                    copy {
+                        from zipTree(inner)
+                        into torLibsDir
+                        rename '(.*)', 'x86_64/lib$1.so'
+                    }
+                }
+            }
+        }
+    }
+    dependsOn cleanTorBinaries
+}
+
+tasks.withType(MergeResources) {
+    inputs.dir torBinariesDir
+    inputs.dir torLibsDir
+    dependsOn unpackTorBinaries
+}
+
 apply from: "${rootProject.rootDir}/gradle/ktlint.gradle"
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/AppModule.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/AppModule.kt
index b8a6bd90022d3a2f2df6cbc383c9eb3cfe9d6b5f..68239a115ae4568bbdf781cf29e6be7174051d97 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/AppModule.kt
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/AppModule.kt
@@ -4,10 +4,12 @@ import dagger.Module
 import dagger.hilt.InstallIn
 import dagger.hilt.components.SingletonComponent
 import org.briarproject.mailbox.core.CoreModule
+import org.briarproject.mailbox.core.tor.AndroidTorModule
 
 @Module(
     includes = [
         CoreModule::class,
+        AndroidTorModule::class,
     ]
 )
 @InstallIn(SingletonComponent::class)
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxApplication.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxApplication.kt
index 939a5acf167312898f38f00e5f61d7f5d4522eeb..a8788fa9413f743ae53caff854effe8d6f0c5735 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxApplication.kt
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxApplication.kt
@@ -2,6 +2,7 @@ package org.briarproject.mailbox.android
 
 import androidx.multidex.MultiDexApplication
 import dagger.hilt.android.HiltAndroidApp
+import org.briarproject.mailbox.core.AndroidEagerSingletons
 import org.briarproject.mailbox.core.CoreEagerSingletons
 import javax.inject.Inject
 
@@ -9,7 +10,10 @@ import javax.inject.Inject
 class MailboxApplication : MultiDexApplication() {
 
     @Inject
-    lateinit var coreEagerSingletons: CoreEagerSingletons
+    internal lateinit var coreEagerSingletons: CoreEagerSingletons
+
+    @Inject
+    internal lateinit var androidEagerSingletons: AndroidEagerSingletons
 
     override fun onCreate() {
         super.onCreate()
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
new file mode 100644
index 0000000000000000000000000000000000000000..baae3905f77ffafb0ce3273466c1149b7614b6d1
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/AndroidEagerSingletons.kt
@@ -0,0 +1,9 @@
+package org.briarproject.mailbox.core
+
+import org.briarproject.mailbox.core.tor.AndroidTorPlugin
+import javax.inject.Inject
+
+@Suppress("unused")
+internal class AndroidEagerSingletons @Inject constructor(
+    val androidTorPlugin: AndroidTorPlugin,
+)
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidTorModule.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidTorModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d1eb0cd88b383bce439f0f99c1b786976b9c75e7
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidTorModule.kt
@@ -0,0 +1,76 @@
+package org.briarproject.mailbox.core.tor
+
+import android.content.Context
+import android.content.res.Resources
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import org.briarproject.mailbox.android.api.system.AndroidWakeLockManager
+import org.briarproject.mailbox.core.lifecycle.IoExecutor
+import org.briarproject.mailbox.core.lifecycle.LifecycleManager
+import org.briarproject.mailbox.core.system.Clock
+import org.briarproject.mailbox.core.system.LocationUtils
+import org.briarproject.mailbox.core.system.ResourceProvider
+import java.util.concurrent.Executor
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal class AndroidTorModule {
+
+    @Provides
+    @Singleton
+    fun provideResourceProvider(@ApplicationContext ctx: Context): ResourceProvider {
+        return ResourceProvider { name, _ ->
+            val res: Resources = ctx.resources
+            // extension is ignored on Android, resources are retrieved without it
+            val resId = res.getIdentifier(name, "raw", ctx.packageName)
+            res.openRawResource(resId)
+        }
+    }
+
+    @Provides
+    @Singleton
+    fun provideAndroidTorPlugin(
+        @ApplicationContext app: Context,
+        @IoExecutor ioExecutor: Executor,
+        networkManager: NetworkManager,
+        locationUtils: LocationUtils,
+        clock: Clock,
+        resourceProvider: ResourceProvider,
+        circumventionProvider: CircumventionProvider,
+        androidWakeLockManager: AndroidWakeLockManager,
+        backoff: Backoff,
+        lifecycleManager: LifecycleManager,
+    ) = AndroidTorPlugin(
+        ioExecutor,
+        app,
+        networkManager,
+        locationUtils,
+        clock,
+        resourceProvider,
+        circumventionProvider,
+        androidWakeLockManager,
+        backoff,
+        architecture,
+        app.getDir("tor", Context.MODE_PRIVATE),
+    ).also { lifecycleManager.registerService(it) }
+
+    private val architecture: String
+        get() {
+            for (abi in AndroidTorPlugin.getSupportedArchitectures()) {
+                return when {
+                    abi.startsWith("x86_64") -> "x86_64_pie"
+                    abi.startsWith("x86") -> "x86_pie"
+                    abi.startsWith("arm64") -> "arm64_pie"
+                    abi.startsWith("armeabi") -> "arm_pie"
+                    else -> continue
+                }
+            }
+//            LOG.info("Tor is not supported on this architecture")
+            return "" // TODO
+        }
+
+}
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
new file mode 100644
index 0000000000000000000000000000000000000000..d25ee11357e787d1e0108329863e51b9a8a7859e
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidTorPlugin.java
@@ -0,0 +1,213 @@
+package org.briarproject.mailbox.core.tor;
+
+import static android.os.Build.VERSION.SDK_INT;
+import static java.util.Arrays.asList;
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Logger.getLogger;
+
+import android.content.Context;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.os.Build;
+
+import org.briarproject.mailbox.android.api.system.AndroidWakeLock;
+import org.briarproject.mailbox.android.api.system.AndroidWakeLockManager;
+import org.briarproject.mailbox.core.system.Clock;
+import org.briarproject.mailbox.core.system.LocationUtils;
+import org.briarproject.mailbox.core.system.ResourceProvider;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.Executor;
+import java.util.logging.Logger;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+public class AndroidTorPlugin extends TorPlugin {
+
+    private static final List<String> LIBRARY_ARCHITECTURES =
+            asList("armeabi-v7a", "arm64-v8a", "x86", "x86_64");
+
+    private static final String TOR_LIB_NAME = "libtor.so";
+    private static final String OBFS4_LIB_NAME = "libobfs4proxy.so";
+
+    private static final Logger LOG =
+            getLogger(AndroidTorPlugin.class.getName());
+
+    private final Context ctx;
+    private final AndroidWakeLock wakeLock;
+    private final File torLib, obfs4Lib;
+
+    AndroidTorPlugin(Executor ioExecutor,
+                     Context ctx,
+                     NetworkManager networkManager,
+                     LocationUtils locationUtils,
+                     Clock clock,
+                     ResourceProvider resourceProvider,
+                     CircumventionProvider circumventionProvider,
+                     AndroidWakeLockManager wakeLockManager,
+                     Backoff backoff,
+                     String architecture,
+                     File torDirectory) {
+        super(ioExecutor, networkManager, locationUtils, clock, resourceProvider, circumventionProvider, backoff, architecture, torDirectory);
+        this.ctx = ctx;
+        wakeLock = wakeLockManager.createWakeLock("TorPlugin");
+        String nativeLibDir = ctx.getApplicationInfo().nativeLibraryDir;
+        torLib = new File(nativeLibDir, TOR_LIB_NAME);
+        obfs4Lib = new File(nativeLibDir, OBFS4_LIB_NAME);
+    }
+
+    @Override
+    protected int getProcessId() {
+        return android.os.Process.myPid();
+    }
+
+    @Override
+    protected long getLastUpdateTime() {
+        try {
+            PackageManager pm = ctx.getPackageManager();
+            PackageInfo pi = pm.getPackageInfo(ctx.getPackageName(), 0);
+            return pi.lastUpdateTime;
+        } catch (NameNotFoundException e) {
+            throw new AssertionError(e);
+        }
+    }
+
+    @Override
+    protected void enableNetwork(boolean enable) throws IOException {
+        if (enable) wakeLock.acquire();
+        super.enableNetwork(enable);
+        if (!enable) wakeLock.release();
+    }
+
+    @Override
+    public void stopService() {
+        super.stopService();
+        wakeLock.release();
+    }
+
+    @Override
+    protected File getTorExecutableFile() {
+        return torLib.exists() ? torLib : super.getTorExecutableFile();
+    }
+
+    @Override
+    protected File getObfs4ExecutableFile() {
+        return obfs4Lib.exists() ? obfs4Lib : super.getObfs4ExecutableFile();
+    }
+
+    @Override
+    protected void installTorExecutable() throws IOException {
+        File extracted = super.getTorExecutableFile();
+        if (torLib.exists()) {
+            // If an older version left behind a Tor binary, delete it
+            if (extracted.exists()) {
+                if (extracted.delete()) LOG.info("Deleted Tor binary");
+                else LOG.info("Failed to delete Tor binary");
+            }
+        } else if (SDK_INT < 29) {
+            // The binary wasn't extracted at install time. Try to extract it
+            extractLibraryFromApk(TOR_LIB_NAME, extracted);
+        } else {
+            // No point extracting the binary, we won't be allowed to execute it
+            throw new FileNotFoundException(torLib.getAbsolutePath());
+        }
+    }
+
+    @Override
+    protected void installObfs4Executable() throws IOException {
+        File extracted = super.getObfs4ExecutableFile();
+        if (obfs4Lib.exists()) {
+            // If an older version left behind an obfs4 binary, delete it
+            if (extracted.exists()) {
+                if (extracted.delete()) LOG.info("Deleted obfs4 binary");
+                else LOG.info("Failed to delete obfs4 binary");
+            }
+        } else if (SDK_INT < 29) {
+            // The binary wasn't extracted at install time. Try to extract it
+            extractLibraryFromApk(OBFS4_LIB_NAME, extracted);
+        } else {
+            // No point extracting the binary, we won't be allowed to execute it
+            throw new FileNotFoundException(obfs4Lib.getAbsolutePath());
+        }
+    }
+
+    private void extractLibraryFromApk(String libName, File dest)
+            throws IOException {
+        File sourceDir = new File(ctx.getApplicationInfo().sourceDir);
+        if (sourceDir.isFile()) {
+            // Look for other APK files in the same directory, if we're allowed
+            File parent = sourceDir.getParentFile();
+            if (parent != null) sourceDir = parent;
+        }
+        List<String> libPaths = getSupportedLibraryPaths(libName);
+        for (File apk : findApkFiles(sourceDir)) {
+            ZipInputStream zin = new ZipInputStream(new FileInputStream(apk));
+            for (ZipEntry e = zin.getNextEntry(); e != null;
+                 e = zin.getNextEntry()) {
+                if (libPaths.contains(e.getName())) {
+                    if (LOG.isLoggable(INFO)) {
+                        LOG.info("Extracting " + e.getName()
+                                + " from " + apk.getAbsolutePath());
+                    }
+                    extract(zin, dest); // Zip input stream will be closed
+                    return;
+                }
+            }
+            zin.close();
+        }
+        throw new FileNotFoundException(libName);
+    }
+
+    /**
+     * Returns all files with the extension .apk or .APK under the given root.
+     */
+    private List<File> findApkFiles(File root) {
+        List<File> files = new ArrayList<>();
+        findApkFiles(root, files);
+        return files;
+    }
+
+    private void findApkFiles(File f, List<File> files) {
+        if (f.isFile() && f.getName().toLowerCase().endsWith(".apk")) {
+            files.add(f);
+        } else if (f.isDirectory()) {
+            File[] children = f.listFiles();
+            if (children != null) {
+                for (File child : children) findApkFiles(child, files);
+            }
+        }
+    }
+
+    /**
+     * Returns the paths at which libraries with the given name would be found
+     * inside an APK file, for all architectures supported by the device, in
+     * order of preference.
+     */
+    private List<String> getSupportedLibraryPaths(String libName) {
+        List<String> architectures = new ArrayList<>();
+        for (String abi : getSupportedArchitectures()) {
+            if (LIBRARY_ARCHITECTURES.contains(abi)) {
+                architectures.add("lib/" + abi + "/" + libName);
+            }
+        }
+        return architectures;
+    }
+
+    static Collection<String> getSupportedArchitectures() {
+        List<String> abis = new ArrayList<>();
+        if (SDK_INT >= 21) {
+            abis.addAll(asList(Build.SUPPORTED_ABIS));
+        } else {
+            abis.add(Build.CPU_ABI);
+            if (Build.CPU_ABI2 != null) abis.add(Build.CPU_ABI2);
+        }
+        return abis;
+    }
+}
diff --git a/mailbox-cli/.gitignore b/mailbox-cli/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..8376b62300be1314da3229f59f10027007198768
--- /dev/null
+++ b/mailbox-cli/.gitignore
@@ -0,0 +1 @@
+/src/main/resources/*.zip
diff --git a/mailbox-cli/build.gradle b/mailbox-cli/build.gradle
index 6cd010ccc834b4a78db83cdd56aa04bf12d6b2e3..e0c9c7d51f2858d7f9709579e9a58a236085214c 100644
--- a/mailbox-cli/build.gradle
+++ b/mailbox-cli/build.gradle
@@ -8,12 +8,25 @@ plugins {
 sourceCompatibility = 1.8
 targetCompatibility = 1.8
 
+configurations {
+    tor
+}
+
 dependencies {
     implementation project(path: ':mailbox-core', configuration: 'default')
 
     implementation 'org.slf4j:slf4j-simple:1.7.30'
     implementation 'com.github.ajalt:clikt:2.2.0'
 
+    def jna_version = '5.8.0'
+    implementation "net.java.dev.jna:jna:$jna_version"
+    implementation "net.java.dev.jna:jna-platform:$jna_version"
+    tor "org.briarproject:tor:$tor_version"
+    tor "org.briarproject:obfs4proxy:$obfs4_version@zip"
+
+    implementation "com.google.dagger:hilt-core:$hilt_version"
+    kapt "com.google.dagger:hilt-compiler:$hilt_version"
+
     testImplementation "org.junit.jupiter:junit-jupiter-api:$junit_version"
     testImplementation "org.junit.jupiter:junit-jupiter-params:$junit_version"
     testImplementation "org.junit.jupiter:junit-jupiter-engine:$junit_version"
@@ -30,3 +43,42 @@ test {
         events "passed", "skipped", "failed"
     }
 }
+
+// At the moment for non-Android projects we need to explicitly mark the code generated by kapt
+// as 'generated source code' for correct highlighting and resolve in IDE.
+idea {
+    module {
+        sourceDirs += file('build/generated/source/kapt/main')
+        testSourceDirs += file('build/generated/source/kapt/test')
+        generatedSourceDirs += file('build/generated/source/kapt/main')
+    }
+}
+
+def torBinariesDir = 'src/main/resources'
+
+task cleanTorBinaries {
+    doLast {
+        delete fileTree(torBinariesDir) { include '*.zip' }
+    }
+}
+
+clean.dependsOn cleanTorBinaries
+
+task unpackTorBinaries {
+    doLast {
+        copy {
+            from configurations.tor.collect { zipTree(it) }
+            into torBinariesDir
+        }
+    }
+    dependsOn cleanTorBinaries
+}
+
+processResources {
+    inputs.dir torBinariesDir
+    dependsOn unpackTorBinaries
+}
+
+tasks.withType(Test) {
+    systemProperty 'java.library.path', 'libs'
+}
diff --git a/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/JavaCliComponent.kt b/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/JavaCliComponent.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f35b13191373058110df60b79e1fb9561d51a2be
--- /dev/null
+++ b/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/JavaCliComponent.kt
@@ -0,0 +1,14 @@
+package org.briarproject.mailbox.cli
+
+import dagger.Component
+import javax.inject.Singleton
+
+@Singleton
+@Component(
+    modules = [
+        JavaCliModule::class,
+    ]
+)
+interface JavaCliComponent {
+    fun inject(main: Main)
+}
diff --git a/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/JavaCliModule.kt b/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/JavaCliModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ee6c354a3c2cf83efe9b2bfed34d1beef61d547c
--- /dev/null
+++ b/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/JavaCliModule.kt
@@ -0,0 +1,16 @@
+package org.briarproject.mailbox.cli
+
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.briarproject.mailbox.core.CoreModule
+import org.briarproject.mailbox.core.tor.JavaTorModule
+
+@Module(
+    includes = [
+        CoreModule::class,
+        JavaTorModule::class,
+    ]
+)
+@InstallIn(SingletonComponent::class)
+internal class JavaCliModule
diff --git a/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/Main.kt b/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/Main.kt
index d8eec8e63a3d539b4824ec46ada496e94acd274f..22f7f445ba12c2c1659a721a5d3a9b8dbff308fe 100644
--- a/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/Main.kt
+++ b/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/Main.kt
@@ -4,19 +4,23 @@ import com.github.ajalt.clikt.core.CliktCommand
 import com.github.ajalt.clikt.parameters.options.counted
 import com.github.ajalt.clikt.parameters.options.flag
 import com.github.ajalt.clikt.parameters.options.option
+import org.briarproject.mailbox.core.CoreEagerSingletons
+import org.briarproject.mailbox.core.JavaCliEagerSingletons
+import org.briarproject.mailbox.core.lifecycle.LifecycleManager
 import org.slf4j.impl.SimpleLogger.DEFAULT_LOG_LEVEL_KEY
 import java.lang.System.setProperty
 import java.util.logging.Level.ALL
 import java.util.logging.Level.INFO
 import java.util.logging.Level.WARNING
 import java.util.logging.LogManager
+import javax.inject.Inject
 
-private class Main : CliktCommand(
+class Main : CliktCommand(
     name = "briar-mailbox",
     help = "Command line interface for the Briar Mailbox"
 ) {
     private val debug by option("--debug", "-d", help = "Enable printing of debug messages").flag(
-        default = false
+        default = true//false
     )
     private val verbosity by option(
         "--verbose",
@@ -24,6 +28,15 @@ private class Main : CliktCommand(
         help = "Print verbose log messages"
     ).counted()
 
+    @Inject
+    internal lateinit var coreEagerSingletons: CoreEagerSingletons
+
+    @Inject
+    internal lateinit var javaCliEagerSingletons: JavaCliEagerSingletons
+
+    @Inject
+    internal lateinit var lifecycleManager: LifecycleManager
+
     override fun run() {
         // logging
         val levelSlf4j = if (debug) "DEBUG" else when (verbosity) {
@@ -40,6 +53,17 @@ private class Main : CliktCommand(
         LogManager.getLogManager().getLogger("").level = level
 
         println("Hello Mailbox")
+
+        val javaCliComponent = DaggerJavaCliComponent.builder().build()
+        javaCliComponent.inject(this)
+
+        Runtime.getRuntime().addShutdownHook(Thread {
+            lifecycleManager.stopServices()
+            lifecycleManager.waitForShutdown()
+        })
+
+        lifecycleManager.startServices()
+        lifecycleManager.waitForStartup()
     }
 
 }
diff --git a/mailbox-cli/src/main/java/org/briarproject/mailbox/core/JavaCliEagerSingletons.kt b/mailbox-cli/src/main/java/org/briarproject/mailbox/core/JavaCliEagerSingletons.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ff5328b0bfca3e9b1fef791e12212a8f704ec039
--- /dev/null
+++ b/mailbox-cli/src/main/java/org/briarproject/mailbox/core/JavaCliEagerSingletons.kt
@@ -0,0 +1,9 @@
+package org.briarproject.mailbox.core
+
+import org.briarproject.mailbox.core.tor.JavaTorPlugin
+import javax.inject.Inject
+
+@Suppress("unused")
+internal class JavaCliEagerSingletons @Inject constructor(
+    val javaTorPlugin: JavaTorPlugin,
+)
diff --git a/mailbox-cli/src/main/java/org/briarproject/mailbox/core/tor/JavaTorModule.kt b/mailbox-cli/src/main/java/org/briarproject/mailbox/core/tor/JavaTorModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..53d14fc7da85d2293c5fe2642618d7ff0eff6275
--- /dev/null
+++ b/mailbox-cli/src/main/java/org/briarproject/mailbox/core/tor/JavaTorModule.kt
@@ -0,0 +1,78 @@
+package org.briarproject.mailbox.core.tor
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.briarproject.mailbox.core.lifecycle.IoExecutor
+import org.briarproject.mailbox.core.lifecycle.LifecycleManager
+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.util.OsUtils.isLinux
+import java.io.File
+import java.util.concurrent.Executor
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal class JavaTorModule {
+
+    @Provides
+    @Singleton
+    fun provideResourceProvider() = ResourceProvider { name, extension ->
+        val cl = javaClass.classLoader
+        cl.getResourceAsStream(name + extension)
+    }
+
+    @Provides
+    @Singleton
+    fun provideJavaTorPlugin(
+        @IoExecutor ioExecutor: Executor,
+        networkManager: NetworkManager,
+        locationUtils: LocationUtils,
+        clock: Clock,
+        resourceProvider: ResourceProvider,
+        circumventionProvider: CircumventionProvider,
+        backoff: Backoff,
+        lifecycleManager: LifecycleManager,
+    ): JavaTorPlugin {
+        val configDir = File(System.getProperty("user.home") + File.separator + ".config")
+        val mailboxDir = File(configDir, ".briar-mailbox")
+        val torDir = File(mailboxDir, "tor")
+        return JavaTorPlugin(
+            ioExecutor,
+            networkManager,
+            locationUtils,
+            clock,
+            resourceProvider,
+            circumventionProvider,
+            backoff,
+            architecture,
+            torDir,
+        ).also { lifecycleManager.registerService(it) }
+    }
+
+    private val architecture: String
+        get() {
+            if (isLinux()) {
+//                if (LOG.isLoggable(Level.INFO)) {
+//                    LOG.info("System's os.arch is $arch")
+//                }
+                when (System.getProperty("os.arch")) {
+                    "amd64" -> {
+                        return "linux-x86_64"
+                    }
+                    "aarch64" -> {
+                        return "linux-aarch64"
+                    }
+                    "arm" -> {
+                        return "linux-armhf"
+                    }
+                }
+            }
+//            LOG.info("Tor is not supported on this architecture")
+            return "" // TODO
+        }
+
+}
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
new file mode 100644
index 0000000000000000000000000000000000000000..0ecdd95f419b492736bec24651c92a96e28a6339
--- /dev/null
+++ b/mailbox-cli/src/main/java/org/briarproject/mailbox/core/tor/JavaTorPlugin.java
@@ -0,0 +1,56 @@
+package org.briarproject.mailbox.core.tor;
+
+import com.sun.jna.Library;
+import com.sun.jna.Native;
+
+import org.briarproject.mailbox.core.system.Clock;
+import org.briarproject.mailbox.core.system.LocationUtils;
+import org.briarproject.mailbox.core.system.ResourceProvider;
+
+import java.io.File;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.CodeSource;
+import java.util.concurrent.Executor;
+
+public class JavaTorPlugin extends TorPlugin {
+
+    JavaTorPlugin(Executor ioExecutor,
+                  NetworkManager networkManager,
+                  LocationUtils locationUtils,
+                  Clock clock,
+                  ResourceProvider resourceProvider,
+                  CircumventionProvider circumventionProvider,
+                  Backoff backoff,
+                  String architecture,
+                  File torDirectory) {
+        super(ioExecutor, 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/build.gradle b/mailbox-core/build.gradle
index 82bc45a5ca36a534ccd29e24542f97f306d71895..8dd8fb3806806261d5caa70aa58dfbbce997a6ab 100644
--- a/mailbox-core/build.gradle
+++ b/mailbox-core/build.gradle
@@ -16,6 +16,8 @@ dependencies {
     implementation "com.google.dagger:hilt-core:$hilt_version"
     kapt "com.google.dagger:dagger-compiler:$hilt_version"
 
+    implementation 'org.briarproject:jtorctl:0.3'
+
     def ktorVersion = '1.6.2'
     implementation "io.ktor:ktor-server-core:$ktorVersion"
     implementation "io.ktor:ktor-server-netty:$ktorVersion"
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreEagerSingletons.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreEagerSingletons.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d6e046bd0406496e3c36fce4a191fe41fb2d093b
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreEagerSingletons.kt
@@ -0,0 +1,9 @@
+package org.briarproject.mailbox.core
+
+import org.briarproject.mailbox.core.server.WebServerManager
+import javax.inject.Inject
+
+@Suppress("unused")
+class CoreEagerSingletons @Inject constructor(
+    val webServerManager: WebServerManager,
+)
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreEagerSingletonsModule.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreEagerSingletonsModule.kt
deleted file mode 100644
index a1e7c297842170d682d509bd1d5b764f5bca4669..0000000000000000000000000000000000000000
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreEagerSingletonsModule.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-package org.briarproject.mailbox.core
-
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
-import org.briarproject.mailbox.core.server.WebServerManager
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Module
-@InstallIn(SingletonComponent::class)
-internal class CoreEagerSingletonsModule {
-
-    @Provides
-    @Singleton
-    fun provideEagerSingletons(webServerManager: WebServerManager): CoreEagerSingletons {
-        return CoreEagerSingletons()
-    }
-
-}
-
-class CoreEagerSingletons {
-
-    @Inject
-    internal lateinit var webServerManager: WebServerManager
-
-}
\ No newline at end of file
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreModule.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreModule.kt
index b14ecb17627129c73639980eb90ffce490c6d0c6..b0c37df01a5b6bfe334310af7385ac377b8b8d17 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreModule.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreModule.kt
@@ -1,19 +1,27 @@
 package org.briarproject.mailbox.core
 
 import dagger.Module
+import dagger.Provides
 import dagger.hilt.InstallIn
 import dagger.hilt.components.SingletonComponent
 import org.briarproject.mailbox.core.db.DatabaseModule
 import org.briarproject.mailbox.core.lifecycle.LifecycleModule
 import org.briarproject.mailbox.core.server.WebServerModule
+import org.briarproject.mailbox.core.system.Clock
+import org.briarproject.mailbox.core.tor.TorModule
+import javax.inject.Singleton
 
 @Module(
     includes = [
-        CoreEagerSingletonsModule::class,
         LifecycleModule::class,
         DatabaseModule::class,
         WebServerModule::class,
+        TorModule::class,
     ]
 )
 @InstallIn(SingletonComponent::class)
-class CoreModule
\ No newline at end of file
+class CoreModule {
+    @Singleton
+    @Provides
+    fun provideClock() = Clock { System.currentTimeMillis() }
+}
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
new file mode 100644
index 0000000000000000000000000000000000000000..671842ce53737f946533ce9d418d28c37260efe4
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/PoliteExecutor.java
@@ -0,0 +1,76 @@
+package org.briarproject.mailbox.core;
+
+import static org.briarproject.mailbox.core.util.LogUtils.now;
+import static java.util.logging.Level.FINE;
+
+import java.util.LinkedList;
+import java.util.Queue;
+import java.util.concurrent.Executor;
+import java.util.logging.Logger;
+
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * An {@link Executor} that delegates its tasks to another {@link Executor}
+ * while limiting the number of tasks that are delegated concurrently. Tasks
+ * are delegated in the order they are submitted to this executor.
+ */
+public class PoliteExecutor implements Executor {
+
+	private final Object lock = new Object();
+	@GuardedBy("lock")
+	private final Queue<Runnable> queue = new LinkedList<>();
+	private final Executor delegate;
+	private final int maxConcurrentTasks;
+	private final Logger log;
+
+	@GuardedBy("lock")
+	private int concurrentTasks = 0;
+
+	/**
+	 * @param tag the tag to be used for logging
+	 * @param delegate the executor to which tasks will be delegated
+	 * @param maxConcurrentTasks the maximum number of tasks that will be
+	 * delegated concurrently. If this is set to 1, tasks submitted to this
+	 * executor will run in the order they are submitted and will not run
+	 * concurrently
+	 */
+	public PoliteExecutor(String tag, Executor delegate,
+                          int maxConcurrentTasks) {
+		this.delegate = delegate;
+		this.maxConcurrentTasks = maxConcurrentTasks;
+		log = Logger.getLogger(tag);
+	}
+
+	@Override
+	public void execute(Runnable r) {
+		long submitted = now();
+		Runnable wrapped = () -> {
+			if (log.isLoggable(FINE)) {
+				long queued = now() - submitted;
+				log.fine("Queue time " + queued + " ms");
+			}
+			try {
+				r.run();
+			} finally {
+				scheduleNext();
+			}
+		};
+		synchronized (lock) {
+			if (concurrentTasks < maxConcurrentTasks) {
+				concurrentTasks++;
+				delegate.execute(wrapped);
+			} else {
+				queue.add(wrapped);
+			}
+		}
+	}
+
+	private void scheduleNext() {
+		synchronized (lock) {
+			Runnable next = queue.poll();
+			if (next == null) concurrentTasks--;
+			else delegate.execute(next);
+		}
+	}
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/IoExecutor.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/IoExecutor.java
new file mode 100644
index 0000000000000000000000000000000000000000..45e8c51f15bc79ca99d84cd20c1e10ef055718d7
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/IoExecutor.java
@@ -0,0 +1,25 @@
+package org.briarproject.mailbox.core.lifecycle;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import javax.inject.Qualifier;
+
+/**
+ * Annotation for injecting the executor for long-running IO tasks. Also used
+ * for annotating methods that should run on the IO executor.
+ * <p>
+ * The contract of this executor is that tasks may be run concurrently, and
+ * submitting a task will never block. Tasks may run indefinitely. Tasks
+ * submitted during shutdown are discarded.
+ */
+@Qualifier
+@Target({FIELD, METHOD, PARAMETER})
+@Retention(RUNTIME)
+public @interface IoExecutor {
+}
\ No newline at end of file
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
new file mode 100644
index 0000000000000000000000000000000000000000..2ed21d9e891f0526ddc496e3514e37c767bc09d8
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/Clock.java
@@ -0,0 +1,5 @@
+package org.briarproject.mailbox.core.system;
+
+public interface Clock {
+    long currentTimeMillis();
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/LocationUtils.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/LocationUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..9ce3cd09eae570fbb00a79d0fb27329611284c5f
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/LocationUtils.java
@@ -0,0 +1,13 @@
+package org.briarproject.mailbox.core.system;
+
+public interface LocationUtils {
+
+	/**
+	 * Get the country the device is currently located in, or "" if it cannot
+	 * be determined.
+	 * <p>
+	 * The country codes are formatted upper-case and as per <a href="
+	 * https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2">ISO 3166-1 alpha 2</a>.
+	 */
+	String getCurrentCountry();
+}
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
new file mode 100644
index 0000000000000000000000000000000000000000..70ec10cd411deeb6dad0c511e7b96cc834502f44
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/ResourceProvider.java
@@ -0,0 +1,8 @@
+package org.briarproject.mailbox.core.system;
+
+import java.io.InputStream;
+
+public interface ResourceProvider {
+
+    InputStream getResourceInputStream(String name, String extension);
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/Backoff.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/Backoff.java
new file mode 100644
index 0000000000000000000000000000000000000000..33aa5f9ee079a4e843bdc15d76ea652995680553
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/Backoff.java
@@ -0,0 +1,22 @@
+package org.briarproject.mailbox.core.tor;
+
+/**
+ * Calculates polling intervals for transport plugins that use backoff.
+ */
+public interface Backoff {
+
+	/**
+	 * Returns the current polling interval.
+	 */
+	int getPollingInterval();
+
+	/**
+	 * Increments the backoff counter.
+	 */
+	void increment();
+
+	/**
+	 * Resets the backoff counter.
+	 */
+	void reset();
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/BackoffImpl.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/BackoffImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..60f420fc69ca6547351dcf799c321b71750bf3bb
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/BackoffImpl.java
@@ -0,0 +1,38 @@
+package org.briarproject.mailbox.core.tor;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import javax.annotation.concurrent.ThreadSafe;
+
+@ThreadSafe
+class BackoffImpl implements Backoff {
+
+	private final int minInterval, maxInterval;
+	private final double base;
+	private final AtomicInteger backoff;
+
+	BackoffImpl(int minInterval, int maxInterval, double base) {
+		this.minInterval = minInterval;
+		this.maxInterval = maxInterval;
+		this.base = base;
+		backoff = new AtomicInteger(0);
+	}
+
+	@Override
+	public int getPollingInterval() {
+		double multiplier = Math.pow(base, backoff.get());
+		// Large or infinite values will be rounded to Integer.MAX_VALUE
+		int interval = (int) (minInterval * multiplier);
+		return Math.min(interval, maxInterval);
+	}
+
+	@Override
+	public void increment() {
+		backoff.incrementAndGet();
+	}
+
+	@Override
+	public void reset() {
+		backoff.set(0);
+	}
+}
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
new file mode 100644
index 0000000000000000000000000000000000000000..f1d33614cc082a19f409921d57e196130f74af68
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/CircumventionProvider.java
@@ -0,0 +1,35 @@
+package org.briarproject.mailbox.core.tor;
+
+import java.util.List;
+
+public interface CircumventionProvider {
+
+	/**
+	 * Countries where Tor is blocked, i.e. vanilla Tor connection won't work.
+	 *
+	 * See https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
+	 * and https://trac.torproject.org/projects/tor/wiki/doc/OONI/censorshipwiki
+	 */
+	String[] BLOCKED = {"CN", "IR", "EG", "BY", "TR", "SY", "VE"};
+
+	/**
+	 * 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" };
+
+	/**
+	 * Countries where obfs4 bridges won't work and meek is needed.
+	 * Should be a subset of {@link #BRIDGES}.
+	 */
+	String[] NEEDS_MEEK = {"CN", "IR"};
+
+	boolean isTorProbablyBlocked(String countryCode);
+
+	boolean doBridgesWork(String countryCode);
+
+	boolean needsMeek(String countryCode);
+
+	List<String> getBridges(boolean meek);
+
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/NetworkManager.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/NetworkManager.java
new file mode 100644
index 0000000000000000000000000000000000000000..fb0428ae86f9bef0461b047eb44884aa9a022e73
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/NetworkManager.java
@@ -0,0 +1,6 @@
+package org.briarproject.mailbox.core.tor;
+
+public interface NetworkManager {
+
+	NetworkStatus getNetworkStatus();
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/NetworkStatus.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/NetworkStatus.java
new file mode 100644
index 0000000000000000000000000000000000000000..8176351ac4b8591051b2a228c297c721c39c0406
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/NetworkStatus.java
@@ -0,0 +1,27 @@
+package org.briarproject.mailbox.core.tor;
+
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+public class NetworkStatus {
+
+	private final boolean connected, wifi, ipv6Only;
+
+	public NetworkStatus(boolean connected, boolean wifi, boolean ipv6Only) {
+		this.connected = connected;
+		this.wifi = wifi;
+		this.ipv6Only = ipv6Only;
+	}
+
+	public boolean isConnected() {
+		return connected;
+	}
+
+	public boolean isWifi() {
+		return wifi;
+	}
+
+	public boolean isIpv6Only() {
+		return ipv6Only;
+	}
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorConstants.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorConstants.java
new file mode 100644
index 0000000000000000000000000000000000000000..f0a2b52b7d1a2ba9a6bebed445bc434ef19256fa
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorConstants.java
@@ -0,0 +1,29 @@
+package org.briarproject.mailbox.core.tor;
+
+public interface TorConstants {
+
+	// Transport properties
+	String PROP_ONION_V3 = "onion3";
+
+	int SOCKS_PORT = 59050;
+	int CONTROL_PORT = 59051;
+
+	int CONNECT_TO_PROXY_TIMEOUT = 5000; // Milliseconds
+	int EXTRA_SOCKET_TIMEOUT = 30000; // Milliseconds
+
+	// Local settings (not shared with contacts)
+	String HS_PRIVATE_KEY_V3 = "onionPrivKey3";
+	String HS_V3_CREATED = "onionPrivKey3Created";
+
+	// Values for PREF_TOR_NETWORK
+	int PREF_TOR_NETWORK_AUTOMATIC = 0;
+	int PREF_TOR_NETWORK_WITHOUT_BRIDGES = 1;
+	int PREF_TOR_NETWORK_WITH_BRIDGES = 2;
+
+	// Default values for local settings
+	boolean DEFAULT_PREF_PLUGIN_ENABLE = true;
+	int DEFAULT_PREF_TOR_NETWORK = PREF_TOR_NETWORK_AUTOMATIC;
+	boolean DEFAULT_PREF_TOR_MOBILE = true;
+	boolean DEFAULT_PREF_TOR_ONLY_WHEN_CHARGING = false;
+
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorModule.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..833f19012bbff51d4f09fcaab2ea1cb7d204dfcb
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorModule.kt
@@ -0,0 +1,63 @@
+package org.briarproject.mailbox.core.tor
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.briarproject.mailbox.core.lifecycle.IoExecutor
+import org.briarproject.mailbox.core.system.LocationUtils
+import java.util.concurrent.Executor
+import java.util.concurrent.SynchronousQueue
+import java.util.concurrent.ThreadPoolExecutor
+import java.util.concurrent.ThreadPoolExecutor.DiscardPolicy
+import java.util.concurrent.TimeUnit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal class TorModule {
+
+    companion object {
+        private const val MIN_POLLING_INTERVAL = 60 * 1000 // 1 minute
+        private const val MAX_POLLING_INTERVAL = 10 * 60 * 1000 // 10 mins
+        private const val BACKOFF_BASE = 1.2
+    }
+
+    @Provides
+    @Singleton
+    fun provideNetworkManager() = NetworkManager {
+        NetworkStatus(true, true, false)
+    }
+
+    @Provides
+    @Singleton
+    fun provideLocationUtils() = LocationUtils { "" }
+
+    @Provides
+    @Singleton
+    fun provideCircumventionProvider(): CircumventionProvider = object : CircumventionProvider {
+        override fun isTorProbablyBlocked(countryCode: String?) = false
+        override fun doBridgesWork(countryCode: String?) = true
+        override fun needsMeek(countryCode: String?) = false
+        override fun getBridges(meek: Boolean): List<String> = emptyList()
+    }
+
+    @Provides
+    @Singleton
+    fun provideBackoff(): Backoff {
+        return BackoffImpl(MIN_POLLING_INTERVAL, MAX_POLLING_INTERVAL, BACKOFF_BASE)
+    }
+
+    @Provides
+    @Singleton
+    @IoExecutor
+    fun provideIoExecutor(): Executor = ThreadPoolExecutor(
+        0,
+        Int.MAX_VALUE,
+        60,
+        TimeUnit.SECONDS,
+        SynchronousQueue(),
+        DiscardPolicy()
+    )
+
+}
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
new file mode 100644
index 0000000000000000000000000000000000000000..eff73f5e95cf65d410d7c847eaeb59f8281af93d
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorPlugin.java
@@ -0,0 +1,647 @@
+package org.briarproject.mailbox.core.tor;
+
+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.TorConstants.CONTROL_PORT;
+import static org.briarproject.mailbox.core.tor.TorPlugin.State.ACTIVE;
+import static org.briarproject.mailbox.core.tor.TorPlugin.State.DISABLED;
+import static org.briarproject.mailbox.core.tor.TorPlugin.State.ENABLING;
+import static org.briarproject.mailbox.core.tor.TorPlugin.State.INACTIVE;
+import static org.briarproject.mailbox.core.tor.TorPlugin.State.STARTING_STOPPING;
+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.logException;
+import static org.briarproject.mailbox.core.util.PrivacyUtils.scrubOnion;
+import static java.util.Arrays.asList;
+import static java.util.Collections.singletonList;
+import static java.util.Collections.singletonMap;
+import static java.util.Objects.requireNonNull;
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+import static java.util.logging.Logger.getLogger;
+
+import net.freehaven.tor.control.EventHandler;
+import net.freehaven.tor.control.TorControlConnection;
+
+import org.briarproject.mailbox.core.PoliteExecutor;
+import org.briarproject.mailbox.core.lifecycle.Service;
+import org.briarproject.mailbox.core.lifecycle.ServiceException;
+import org.briarproject.mailbox.core.system.Clock;
+import org.briarproject.mailbox.core.system.LocationUtils;
+import org.briarproject.mailbox.core.system.ResourceProvider;
+
+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.ServerSocket;
+import java.net.Socket;
+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.logging.Logger;
+import java.util.zip.ZipInputStream;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+import javax.annotation.concurrent.ThreadSafe;
+
+abstract class TorPlugin implements Service, EventHandler {
+
+    private static final Logger LOG = getLogger(TorPlugin.class.getName());
+
+    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 NetworkManager networkManager;
+    private final LocationUtils locationUtils;
+    private final Clock clock;
+    private final Backoff backoff;
+    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,
+                        NetworkManager networkManager,
+                        LocationUtils locationUtils,
+                        Clock clock,
+                        ResourceProvider resourceProvider,
+                        CircumventionProvider circumventionProvider,
+                        Backoff backoff,
+                        String architecture,
+                        File torDirectory) {
+        this.ioExecutor = ioExecutor;
+        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.warning("Could not create Tor directory.");
+                throw new ServiceException();
+            }
+        }
+        // Install or update the assets if necessary
+        if (!assetsAreUpToDate()) installAssets();
+        if (cookieFile.exists() && !cookieFile.delete())
+            LOG.warning("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.isLoggable(INFO)) {
+            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) {
+                if (LOG.isLoggable(WARNING))
+                    LOG.warning("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.warning("Auth cookie not created");
+                    if (LOG.isLoggable(INFO)) listFiles(torDirectory);
+                    throw new ServiceException();
+                }
+                //noinspection BusyWait
+                Thread.sleep(COOKIE_POLLING_INTERVAL_MS);
+            }
+            LOG.info("Auth cookie created");
+        } catch (InterruptedException e) {
+            LOG.warning("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("8888"));
+    }
+
+    private boolean assetsAreUpToDate() {
+        return doneFile.lastModified() > getLastUpdateTime();
+    }
+
+    private void installAssets() throws ServiceException {
+        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.warning("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 {
+        if (LOG.isLoggable(INFO))
+            LOG.info("Installing Tor binary for " + architecture);
+        File torFile = getTorExecutableFile();
+        extract(getTorInputStream(), torFile);
+        if (!torFile.setExecutable(true, true)) throw new IOException();
+    }
+
+    protected void installObfs4Executable() throws IOException {
+        if (LOG.isLoggable(INFO))
+            LOG.info("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, WARNING);
+        }
+    }
+
+    private void publishHiddenService(String port) {
+        if (!state.isTorRunning()) return;
+        // TODO get stored key
+        String privKey3 = null;
+        publishV3HiddenService(port, privKey3);
+    }
+
+    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, WARNING, e);
+            return;
+        }
+        if (!response.containsKey(HS_ADDRESS)) {
+            LOG.warning("Tor did not return a hidden service address");
+            return;
+        }
+        if (privKey == null && !response.containsKey(HS_PRIVKEY)) {
+            LOG.warning("Tor did not return a private key");
+            return;
+        }
+        String onion3 = response.get(HS_ADDRESS);
+        if (LOG.isLoggable(INFO)) {
+            LOG.info("V3 hidden service " + scrubOnion(onion3));
+        }
+        // TODO remove
+        LOG.warning("V3 hidden service: http://" + onion3 + ".onion");
+        if (privKey == null) {
+            // TODO Save the hidden service's onion hostname
+//			p.put(PROP_ONION_V3, onion3);
+            // TODO Save the hidden service's private key for next time
+//			s.put(HS_PRIVATE_KEY_V3, response.get(HS_PRIVKEY));
+//			s.put(HS_V3_CREATED, String.valueOf(clock.currentTimeMillis()));
+        }
+    }
+
+    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, WARNING);
+        if (controlSocket != null && controlConnection != null) {
+            try {
+                LOG.info("Stopping Tor");
+                controlConnection.setConf("DisableNetwork", "1");
+                controlConnection.shutdownTor("TERM");
+                controlSocket.close();
+            } catch (IOException e) {
+                logException(LOG, WARNING, 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) {
+        if (LOG.isLoggable(INFO))
+            LOG.info("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) {
+        if (LOG.isLoggable(INFO)) LOG.info(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");
+        }
+    }
+
+    public void onNetworkStatusChanged() {
+        updateConnectionStatus(networkManager.getNetworkStatus());
+    }
+
+    private void disableNetwork() {
+        connectionStatusExecutor.execute(() -> {
+            try {
+                if (state.isTorRunning()) enableNetwork(false);
+            } catch (IOException ex) {
+                logException(LOG, WARNING, 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.isLoggable(INFO)) {
+                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, WARNING, 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;
+        }
+
+        @Nullable
+        synchronized ServerSocket setStopped() {
+            stopped = true;
+            ServerSocket ss = serverSocket;
+            serverSocket = null;
+//            callback.pluginStateChanged(getState());
+            return ss;
+        }
+
+        synchronized void setBootstrapped() {
+            bootstrapped = true;
+//            callback.pluginStateChanged(getState());
+        }
+
+        synchronized boolean getAndSetCircuitBuilt() {
+            boolean firstCircuit = !circuitBuilt;
+            circuitBuilt = true;
+//            callback.pluginStateChanged(getState());
+            return firstCircuit;
+        }
+
+        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;
+//            callback.pluginStateChanged(getState());
+        }
+
+        // Doesn't affect getState()
+        synchronized boolean setServerSocket(ServerSocket ss) {
+            if (stopped || serverSocket != null) return false;
+            serverSocket = ss;
+            return true;
+        }
+
+        // Doesn't affect getState()
+        synchronized void clearServerSocket(ServerSocket ss) {
+            if (serverSocket == ss) serverSocket = null;
+        }
+
+        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
new file mode 100644
index 0000000000000000000000000000000000000000..116363f427b252ecee8ba8796460627f9c92db5f
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/IoUtils.java
@@ -0,0 +1,123 @@
+package org.briarproject.mailbox.core.util;
+
+import static org.briarproject.mailbox.core.util.LogUtils.logException;
+import static java.util.logging.Level.WARNING;
+
+import java.io.Closeable;
+import java.io.EOFException;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import javax.annotation.Nullable;
+
+public class IoUtils {
+
+	private static final Logger LOG = Logger.getLogger(IoUtils.class.getName());
+
+	public static void deleteFileOrDir(File f) {
+		if (f.isFile()) {
+			delete(f);
+		} else if (f.isDirectory()) {
+			File[] children = f.listFiles();
+			if (children == null) {
+				if (LOG.isLoggable(WARNING)) {
+					LOG.warning("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() && LOG.isLoggable(WARNING))
+			LOG.warning("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, WARNING);
+			tryToClose(out, LOG, WARNING);
+		}
+	}
+
+	public static void tryToClose(@Nullable Closeable c, Logger logger,
+			Level level) {
+		try {
+			if (c != null) c.close();
+		} catch (IOException e) {
+			logException(logger, level, e);
+		}
+	}
+
+	public static void tryToClose(@Nullable Socket s, Logger logger,
+			Level level) {
+		try {
+			if (s != null) s.close();
+		} catch (IOException e) {
+			logException(logger, level, e);
+		}
+	}
+
+	public static void tryToClose(@Nullable ServerSocket ss, Logger logger,
+			Level level) {
+		try {
+			if (ss != null) ss.close();
+		} catch (IOException e) {
+			logException(logger, level, e);
+		}
+	}
+
+	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;
+	}
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/OsUtils.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/OsUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..52698ab2aad08cd1ea9a1efab26713b15c97550c
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/OsUtils.java
@@ -0,0 +1,27 @@
+package org.briarproject.mailbox.core.util;
+
+import javax.annotation.Nullable;
+
+public class OsUtils {
+
+	@Nullable
+	private static final String os = System.getProperty("os.name");
+	@Nullable
+	private static final String vendor = System.getProperty("java.vendor");
+
+	public static boolean isWindows() {
+		return os != null && os.contains("Windows");
+	}
+
+	public static boolean isMac() {
+		return os != null && os.contains("Mac OS");
+	}
+
+	public static boolean isLinux() {
+		return os != null && os.contains("Linux") && !isAndroid();
+	}
+
+	public static boolean isAndroid() {
+		return vendor != null && vendor.contains("Android");
+	}
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/PrivacyUtils.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/PrivacyUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..246d4d2c99e1f5b997bdfa839a3b281253ef7bc7
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/PrivacyUtils.java
@@ -0,0 +1,64 @@
+package org.briarproject.mailbox.core.util;
+
+import static org.briarproject.mailbox.core.util.StringUtils.isNullOrEmpty;
+import static org.briarproject.mailbox.core.util.StringUtils.isValidMac;
+import static org.briarproject.mailbox.core.util.StringUtils.toHexString;
+
+import java.net.Inet4Address;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+
+import javax.annotation.Nullable;
+
+public class PrivacyUtils {
+
+	public static String scrubOnion(String onion) {
+		// keep first three characters of onion address
+		return onion.substring(0, 3) + "[scrubbed]";
+	}
+
+	@Nullable
+	public static String scrubMacAddress(@Nullable String address) {
+		if (isNullOrEmpty(address) || !isValidMac(address)) return address;
+		// this is a fake address we need to know about
+		if (address.equals("02:00:00:00:00:00")) return address;
+		// keep first and last octet of MAC address
+		return address.substring(0, 3) + "[scrubbed]"
+				+ address.substring(14, 17);
+	}
+
+	public static String scrubInetAddress(InetAddress address) {
+		if (address instanceof Inet4Address) {
+			// Don't scrub local IPv4 addresses
+			if (address.isLoopbackAddress() || address.isLinkLocalAddress() ||
+					address.isSiteLocalAddress()) {
+				return address.getHostAddress();
+			}
+			// Keep first and last octet of non-local IPv4 addresses
+			return scrubIpv4Address(address.getAddress());
+		} else {
+			// Keep first and last octet of IPv6 addresses
+			return scrubIpv6Address(address.getAddress());
+		}
+	}
+
+	private static String scrubIpv4Address(byte[] ipv4) {
+		return (ipv4[0] & 0xFF) + ".[scrubbed]." + (ipv4[3] & 0xFF);
+	}
+
+	private static String scrubIpv6Address(byte[] ipv6) {
+		String hex = toHexString(ipv6).toLowerCase();
+		return hex.substring(0, 2) + "[scrubbed]" + hex.substring(30);
+	}
+
+	public static String scrubSocketAddress(InetSocketAddress address) {
+		return scrubInetAddress(address.getAddress());
+	}
+
+	public static String scrubSocketAddress(SocketAddress address) {
+		if (address instanceof InetSocketAddress)
+			return scrubSocketAddress((InetSocketAddress) address);
+		return "[scrubbed]";
+	}
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/StringUtils.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/StringUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..2a5cf7991ddecd9fa982300a824c78b03808fd75
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/StringUtils.java
@@ -0,0 +1,162 @@
+package org.briarproject.mailbox.core.util;
+
+import static java.nio.charset.CodingErrorAction.IGNORE;
+import static java.util.regex.Pattern.CASE_INSENSITIVE;
+
+import java.io.UnsupportedEncodingException;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetDecoder;
+import java.util.Collection;
+import java.util.Random;
+import java.util.regex.Pattern;
+
+import javax.annotation.Nullable;
+
+public class StringUtils {
+
+	private static final Charset UTF_8 = Charset.forName("UTF-8");
+	private static Pattern MAC = Pattern.compile("[0-9a-f]{2}:[0-9a-f]{2}:" +
+					"[0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2}",
+			CASE_INSENSITIVE);
+
+	private static final char[] HEX = new char[] {
+			'0', '1', '2', '3', '4', '5', '6', '7',
+			'8', '9', 'A', 'B', 'C', 'D', 'E', 'F'
+	};
+	private static final Random random = new Random();
+
+	public static boolean isNullOrEmpty(@Nullable String s) {
+		return s == null || s.length() == 0;
+	}
+
+	public static String join(Collection<String> strings, String separator) {
+		StringBuilder joined = new StringBuilder();
+		for (String s : strings) {
+			if (joined.length() > 0) joined.append(separator);
+			joined.append(s);
+		}
+		return joined.toString();
+	}
+
+	public static byte[] toUtf8(String s) {
+		try {
+			return s.getBytes("UTF-8");
+		} catch (UnsupportedEncodingException e) {
+			throw new AssertionError(e);
+		}
+	}
+
+	public static String fromUtf8(byte[] bytes) {
+		return fromUtf8(bytes, 0, bytes.length);
+	}
+
+	public static String fromUtf8(byte[] bytes, int off, int len) {
+		CharsetDecoder decoder = UTF_8.newDecoder();
+		decoder.onMalformedInput(IGNORE);
+		decoder.onUnmappableCharacter(IGNORE);
+		ByteBuffer buffer = ByteBuffer.wrap(bytes, off, len);
+		try {
+			return decoder.decode(buffer).toString();
+		} catch (CharacterCodingException e) {
+			throw new AssertionError(e);
+		}
+	}
+
+	public static String truncateUtf8(String s, int maxUtf8Length) {
+		byte[] utf8 = toUtf8(s);
+		if (utf8.length <= maxUtf8Length) return s;
+		return fromUtf8(utf8, 0, maxUtf8Length);
+	}
+
+	/**
+	 * Converts the given byte array to a hex character array.
+	 */
+	private static char[] toHexChars(byte[] bytes) {
+		char[] hex = new char[bytes.length * 2];
+		for (int i = 0, j = 0; i < bytes.length; i++) {
+			hex[j++] = HEX[(bytes[i] >> 4) & 0xF];
+			hex[j++] = HEX[bytes[i] & 0xF];
+		}
+		return hex;
+	}
+
+	/**
+	 * Converts the given byte array to a hex string.
+	 */
+	public static String toHexString(byte[] bytes) {
+		return new String(toHexChars(bytes));
+	}
+
+	/**
+	 * Converts the given hex string to a byte array.
+	 */
+	public static byte[] fromHexString(String hex) {
+		int len = hex.length();
+		if (len % 2 != 0)
+			throw new IllegalArgumentException("Not a hex string");
+		byte[] bytes = new byte[len / 2];
+		for (int i = 0, j = 0; i < len; i += 2, j++) {
+			int high = hexDigitToInt(hex.charAt(i));
+			int low = hexDigitToInt(hex.charAt(i + 1));
+			bytes[j] = (byte) ((high << 4) + low);
+		}
+		return bytes;
+	}
+
+	private static int hexDigitToInt(char c) {
+		if (c >= '0' && c <= '9') return c - '0';
+		if (c >= 'A' && c <= 'F') return c - 'A' + 10;
+		if (c >= 'a' && c <= 'f') return c - 'a' + 10;
+		throw new IllegalArgumentException("Not a hex digit: " + c);
+	}
+
+	public static String trim(String s) {
+		return s.trim();
+	}
+
+	/**
+	 * Returns true if the string is longer than maxLength
+	 */
+	public static boolean utf8IsTooLong(String s, int maxLength) {
+		return toUtf8(s).length > maxLength;
+	}
+
+	public static boolean isValidMac(String mac) {
+		return MAC.matcher(mac).matches();
+	}
+
+	public static byte[] macToBytes(String mac) {
+		if (!MAC.matcher(mac).matches()) throw new IllegalArgumentException();
+		return fromHexString(mac.replaceAll(":", ""));
+	}
+
+	public static String macToString(byte[] mac) {
+		if (mac.length != 6) throw new IllegalArgumentException();
+		StringBuilder s = new StringBuilder();
+		for (byte b : mac) {
+			if (s.length() > 0) s.append(':');
+			s.append(HEX[(b >> 4) & 0xF]);
+			s.append(HEX[b & 0xF]);
+		}
+		return s.toString();
+	}
+
+	public static String getRandomString(int length) {
+		char[] c = new char[length];
+		for (int i = 0; i < length; i++)
+			c[i] = (char) ('a' + random.nextInt(26));
+		return new String(c);
+	}
+
+	public static String getRandomBase32String(int length) {
+		char[] c = new char[length];
+		for (int i = 0; i < length; i++) {
+			int character = random.nextInt(32);
+			if (character < 26) c[i] = (char) ('a' + character);
+			else c[i] = (char) ('2' + (character - 26));
+		}
+		return new String(c);
+	}
+}
diff --git a/mailbox-core/src/main/resources/bridges b/mailbox-core/src/main/resources/bridges
new file mode 100644
index 0000000000000000000000000000000000000000..73188eba1dddfb6784faca58bccd6f2c7c1973dc
--- /dev/null
+++ b/mailbox-core/src/main/resources/bridges
@@ -0,0 +1,10 @@
+Bridge obfs4 37.218.245.14:38224 D9A82D2F9C2F65A18407B1D2B764F130847F8B5D cert=bjRaMrr1BRiAW8IE9U5z27fQaYgOhX1UCmOpg2pFpoMvo6ZgQMzLsaTzzQNTlm7hNcb+Sg iat-mode=0
+Bridge obfs4 85.31.186.26:443 91A6354697E6B02A386312F68D82CF86824D3606 cert=PBwr+S8JTVZo6MPdHnkTwXJPILWADLqfMGoVvhZClMq/Urndyd42BwX9YFJHZnBB3H0XCw iat-mode=0
+Bridge obfs4 193.11.166.194:27015 2D82C2E354D531A68469ADF7F878FA6060C6BACA cert=4TLQPJrTSaDffMK7Nbao6LC7G9OW/NHkUwIdjLSS3KYf0Nv4/nQiiI8dY2TcsQx01NniOg iat-mode=0
+Bridge obfs4 193.11.166.194:27020 86AC7B8D430DAC4117E9F42C9EAED18133863AAF cert=0LDeJH4JzMDtkJJrFphJCiPqKx7loozKN7VNfuukMGfHO0Z8OGdzHVkhVAOfo1mUdv9cMg iat-mode=0
+Bridge obfs4 193.11.166.194:27025 1AE2C08904527FEA90C4C4F8C1083EA59FBC6FAF cert=ItvYZzW5tn6v3G4UnQa6Qz04Npro6e81AP70YujmK/KXwDFPTs3aHXcHp4n8Vt6w/bv8cA iat-mode=0
+Bridge obfs4 209.148.46.65:443 74FAD13168806246602538555B5521A0383A1875 cert=ssH+9rP8dG2NLDN2XuFw63hIO/9MNNinLmxQDpVa+7kTOa9/m+tGWT1SmSYpQ9uTBGa6Hw iat-mode=0
+Bridge obfs4 45.145.95.6:27015 C5B7CD6946FF10C5B3E89691A7D3F2C122D2117C cert=TD7PbUO0/0k6xYHMPW3vJxICfkMZNdkRrb63Zhl5j9dW3iRGiCx0A7mPhe5T2EDzQ35+Zw iat-mode=0
+Bridge obfs4 51.222.13.177:80 5EDAC3B810E12B01F6FD8050D2FD3E277B289A08 cert=2uplIpLQ0q9+0qMFrK5pkaYRDOe460LL9WHBvatgkuRr/SL31wBOEupaMMJ6koRE6Ld0ew iat-mode=0
+Bridge obfs4 78.46.188.239:37356 5A2D2F4158D0453E00C7C176978D3F41D69C45DB cert=3c0SwxpOisbohNxEc4tb875RVW8eOu1opRTVXJhafaKA/PNNtI7ElQIVOVZg1AdL5bxGCw iat-mode=0
+Bridge meek_lite 192.0.2.2:2 97700DFE9F483596DDA6264C4D7DF7641E1E39CE url=https://meek.azureedge.net/ front=ajax.aspnetcdn.com
\ No newline at end of file
diff --git a/mailbox-core/src/main/resources/torrc b/mailbox-core/src/main/resources/torrc
new file mode 100644
index 0000000000000000000000000000000000000000..3d27a7f20593c2902a0620cdc0fe5f3906781ab9
--- /dev/null
+++ b/mailbox-core/src/main/resources/torrc
@@ -0,0 +1,6 @@
+ControlPort 59051
+CookieAuthentication 1
+DisableNetwork 1
+RunAsDaemon 1
+SafeSocks 1
+SocksPort 59050