diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/core/AndroidEagerSingletons.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/core/AndroidEagerSingletons.kt
index 1269bb461bb447eb2be2d10f035fcd14a55ac9d4..d477e73e75133cacd5fa2e7373d7a6ccec4ab22e 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/core/AndroidEagerSingletons.kt
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/AndroidEagerSingletons.kt
@@ -2,12 +2,12 @@ package org.briarproject.mailbox.core
 
 import org.briarproject.mailbox.core.system.AndroidTaskScheduler
 import org.briarproject.mailbox.core.tor.AndroidNetworkManager
-import org.briarproject.mailbox.core.tor.AndroidTorPlugin
+import org.briarproject.mailbox.core.tor.TorPlugin
 import javax.inject.Inject
 
 @Suppress("unused")
 internal class AndroidEagerSingletons @Inject constructor(
     val androidTaskScheduler: AndroidTaskScheduler,
     val androidNetworkManager: AndroidNetworkManager,
-    val androidTorPlugin: AndroidTorPlugin,
+    val androidTorPlugin: TorPlugin,
 )
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
index 369f926cd02ee4b8f49ac56261dd23e163afbcb8..b750cf91afd42a1c830a51205b9d0c329d00135a 100644
--- 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
@@ -54,7 +54,7 @@ internal class AndroidTorModule {
         backoff: Backoff,
         lifecycleManager: LifecycleManager,
         eventBus: EventBus,
-    ) = AndroidTorPlugin(
+    ): TorPlugin = AndroidTorPlugin(
         ioExecutor,
         app,
         settingsManager,
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
index 446a1cc524401b85bd76e56e518c5c7f84d652bf..8e1830fdd027270ab403c91053973ef59ccc5933 100644
--- a/mailbox-cli/src/main/java/org/briarproject/mailbox/core/JavaCliEagerSingletons.kt
+++ b/mailbox-cli/src/main/java/org/briarproject/mailbox/core/JavaCliEagerSingletons.kt
@@ -1,11 +1,11 @@
 package org.briarproject.mailbox.core
 
 import org.briarproject.mailbox.core.system.TaskScheduler
-import org.briarproject.mailbox.core.tor.JavaTorPlugin
+import org.briarproject.mailbox.core.tor.TorPlugin
 import javax.inject.Inject
 
 @Suppress("unused")
 internal class JavaCliEagerSingletons @Inject constructor(
     val taskScheduler: TaskScheduler,
-    val javaTorPlugin: JavaTorPlugin,
+    val javaTorPlugin: TorPlugin,
 )
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
index 051edfe6a889f91e80d11fef66db36ae1ff9f97e..d281a492bc79c740e49180ecfe8124376722ab36 100644
--- 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
@@ -47,7 +47,7 @@ internal class JavaTorModule {
         backoff: Backoff,
         lifecycleManager: LifecycleManager,
         eventBus: EventBus,
-    ): JavaTorPlugin {
+    ): TorPlugin {
         val configDir = File(System.getProperty("user.home") + File.separator + ".config")
         val mailboxDir = File(configDir, ".briar-mailbox")
         val torDir = File(mailboxDir, "tor")
diff --git a/mailbox-core/build.gradle b/mailbox-core/build.gradle
index 198bff74dfbca8d44375f04a8b78fe24d8b7a4b1..43bc03c273d58bb1bc2f4c32dc23e030d7016e00 100644
--- a/mailbox-core/build.gradle
+++ b/mailbox-core/build.gradle
@@ -28,6 +28,9 @@ dependencies {
 
     implementation 'com.h2database:h2:1.4.192' // The last version that supports Java 1.6
 
+    // Base 32
+    implementation 'dev.keiji.rfc4648:rfc4648:1.0.0'
+
     testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
     testImplementation "org.junit.jupiter:junit-jupiter-api:$junit_version"
     testImplementation "org.junit.jupiter:junit-jupiter-params:$junit_version"
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/QrCodeEncoder.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/QrCodeEncoder.kt
new file mode 100644
index 0000000000000000000000000000000000000000..4857bcc1d1158e0bd55eaf29adee76d3e5fb021e
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/QrCodeEncoder.kt
@@ -0,0 +1,69 @@
+package org.briarproject.mailbox.core.setup
+
+import dev.keiji.util.Base32
+import org.briarproject.mailbox.core.db.Database
+import org.briarproject.mailbox.core.db.DbException
+import org.briarproject.mailbox.core.tor.TorPlugin
+import org.briarproject.mailbox.core.util.LogUtils.logException
+import org.briarproject.mailbox.core.util.StringUtils.fromHexString
+import org.slf4j.LoggerFactory.getLogger
+import java.nio.ByteBuffer
+import javax.inject.Inject
+
+private const val VERSION = 32
+private val LOG = getLogger(QrCodeEncoder::class.java)
+
+class QrCodeEncoder @Inject constructor(
+    private val db: Database,
+    private val setupManager: SetupManager,
+    private val torPlugin: TorPlugin,
+) {
+
+    fun getQrCodeBytes(): ByteArray? {
+        val hiddenServiceBytes = getHiddenServiceBytes() ?: return null
+        val setupTokenBytes = getSetupTokenBytes() ?: return null
+        return ByteBuffer.allocate(65)
+            .put(VERSION.toByte()) // 1
+            .put(hiddenServiceBytes) // 32
+            .put(setupTokenBytes) // 32
+            .array()
+    }
+
+    /**
+     * https://gitweb.torproject.org/torspec.git/tree/rend-spec-v3.txt?id=29245fd5#n2135
+     */
+    private fun getHiddenServiceBytes(): ByteArray? {
+        val addressString = try {
+            torPlugin.hiddenServiceAddress
+        } catch (e: DbException) {
+            logException(LOG, e)
+            return null
+        }
+        if (addressString == null) {
+            LOG.error("Hidden service address not yet available")
+            return null
+        }
+        LOG.error(addressString)
+        val addressBytes = Base32.decode(addressString.uppercase())
+        check(addressBytes.size == 35) { "$addressString not 35 bytes long" }
+        return addressBytes.copyOfRange(0, 32)
+    }
+
+    private fun getSetupTokenBytes(): ByteArray? {
+        val tokenString = try {
+            db.transactionWithResult(true) { txn ->
+                setupManager.getSetupToken(txn)
+            }
+        } catch (e: DbException) {
+            logException(LOG, e)
+            return null
+        }
+        if (tokenString == null) {
+            LOG.error("Setup token not available")
+            return null
+        }
+        val tokenBytes = fromHexString(tokenString)
+        check(tokenBytes.size == 32) { "$tokenString not 32 bytes long" }
+        return tokenBytes
+    }
+}
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 05929a6ef0dd4fdd7bcbf6fb2eaab759ddc7c1bc..276a592e30e1569c757afc60e696b5c14128ca83 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
@@ -63,7 +63,8 @@ import static org.briarproject.mailbox.core.util.LogUtils.warn;
 import static org.briarproject.mailbox.core.util.PrivacyUtils.scrubOnion;
 import static org.slf4j.LoggerFactory.getLogger;
 
-abstract class TorPlugin implements Service, EventHandler, EventListener {
+public abstract class TorPlugin
+        implements Service, EventHandler, EventListener {
 
     private static final Logger LOG = getLogger(TorPlugin.class);
 
@@ -376,12 +377,18 @@ abstract class TorPlugin implements Service, EventHandler, EventListener {
 
         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);
+            }
         }
-        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 {