diff --git a/build.gradle b/build.gradle
index 536485f0f25ff50270b4a685e6269cdaf92e1240..57f585334b459cbaadb3af645fc1368e4f8cdb57 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,6 +1,7 @@
 buildscript {
     ext.kotlin_version = '1.6.10'
     ext.hilt_version = '2.40'
+    ext.nav_version = '2.4.0'
     ext.tor_version = '0.3.5.15'
     ext.obfs4_version = '0.0.12-dev-40245c4a'
     ext.junit_version = '5.7.2'
@@ -18,6 +19,7 @@ buildscript {
         classpath 'com.android.tools.build:gradle:7.0.4'
         classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
         classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
+        classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
     }
 }
 
diff --git a/mailbox-android/build.gradle b/mailbox-android/build.gradle
index 744978cd48c4a17c8b4ca50a2abe1ac1d443f6c7..b692d5392654f4d934b7f2d2dd8a9f47cd2bb069 100644
--- a/mailbox-android/build.gradle
+++ b/mailbox-android/build.gradle
@@ -2,6 +2,7 @@ import com.android.build.gradle.tasks.MergeResources
 
 plugins {
     id 'com.android.application'
+    id 'androidx.navigation.safeargs'
     id 'kotlin-android'
     id 'kotlin-kapt'
     id 'dagger.hilt.android.plugin'
@@ -69,7 +70,6 @@ dependencies {
     implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
     implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version"
 
-    def nav_version = "2.4.0"
     implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
     implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
 
diff --git a/mailbox-android/src/main/AndroidManifest.xml b/mailbox-android/src/main/AndroidManifest.xml
index 4466f33c0ae170dc3a788afb1c808dce5cbeb4ac..c763be4838e818e3cc332f4d537e67503c833a37 100644
--- a/mailbox-android/src/main/AndroidManifest.xml
+++ b/mailbox-android/src/main/AndroidManifest.xml
@@ -25,7 +25,7 @@
         <service android:name=".android.MailboxService" />
 
         <activity
-            android:name=".android.MainActivity"
+            android:name=".android.ui.MainActivity"
             android:exported="true">
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/ApplicationComponent.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ApplicationComponent.kt
index b03d4581b6a129cc44ce68d41b13263e6faac2ba..820f0a1a3fbf57369a7a7e3f9936b46072361f68 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/ApplicationComponent.kt
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ApplicationComponent.kt
@@ -20,6 +20,7 @@
 package org.briarproject.mailbox.android
 
 import dagger.Component
+import org.briarproject.mailbox.android.ui.MainActivity
 
 @Component(
     modules = [
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxNotificationManager.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxNotificationManager.kt
index 2673e52e8beb121e19d18276954a3d6e29694e47..6d8eda6b9ea5d5ecaa45e5c72dea991dd95400a2 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxNotificationManager.kt
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxNotificationManager.kt
@@ -34,6 +34,7 @@ import androidx.core.app.NotificationCompat.PRIORITY_MIN
 import androidx.core.content.ContextCompat.getSystemService
 import dagger.hilt.android.qualifiers.ApplicationContext
 import org.briarproject.mailbox.R
+import org.briarproject.mailbox.android.ui.MainActivity
 import javax.inject.Inject
 import javax.inject.Singleton
 
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/QrCodeUtils.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/QrCodeUtils.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c8448c8c33f94364a8789c005f1c6cd940fc1379
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/QrCodeUtils.kt
@@ -0,0 +1,39 @@
+/*
+ *     Briar Mailbox
+ *     Copyright (C) 2021-2022  The Briar Project
+ *
+ *     This program is free software: you can redistribute it and/or modify
+ *     it under the terms of the GNU Affero General Public License as
+ *     published by the Free Software Foundation, either version 3 of the
+ *     License, or (at your option) any later version.
+ *
+ *     This program is distributed in the hope that it will be useful,
+ *     but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *     GNU Affero General Public License for more details.
+ *
+ *     You should have received a copy of the GNU Affero General Public License
+ *     along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ */
+package org.briarproject.mailbox.android
+
+import com.google.zxing.common.BitMatrix
+import android.graphics.Bitmap
+import android.graphics.Color
+
+object QrCodeUtils {
+    fun renderQrCode(matrix: BitMatrix): Bitmap {
+        val width = matrix.width
+        val height = matrix.height
+        val pixels = IntArray(width * height)
+        for (x in 0 until width) {
+            for (y in 0 until height) {
+                pixels[y * width + x] = if (matrix[x, y]) Color.BLACK else Color.WHITE
+            }
+        }
+        val qr = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+        qr.setPixels(pixels, 0, width, 0, 0, width, height)
+        return qr
+    }
+}
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/dontkillme/DoNotKillMeFragment.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/dontkillme/DoNotKillMeFragment.kt
index 5c64d6b7059290f1bbf3af76c71d3db4b41bb2b5..06a81ccf19b60bd18388d5a9152424a85f7c2bc6 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/dontkillme/DoNotKillMeFragment.kt
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/dontkillme/DoNotKillMeFragment.kt
@@ -21,7 +21,7 @@ package org.briarproject.mailbox.android.dontkillme
 
 import androidx.fragment.app.activityViewModels
 import dagger.hilt.android.AndroidEntryPoint
-import org.briarproject.mailbox.android.MailboxViewModel
+import org.briarproject.mailbox.android.ui.MailboxViewModel
 
 @AndroidEntryPoint
 class DoNotKillMeFragment : AbstractDoNotKillMeFragment() {
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/InitFragment.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/InitFragment.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0c6962e14f99e649f75ceca6458fc478d3dbb93c
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/InitFragment.kt
@@ -0,0 +1,58 @@
+/*
+ *     Briar Mailbox
+ *     Copyright (C) 2021-2022  The Briar Project
+ *
+ *     This program is free software: you can redistribute it and/or modify
+ *     it under the terms of the GNU Affero General Public License as
+ *     published by the Free Software Foundation, either version 3 of the
+ *     License, or (at your option) any later version.
+ *
+ *     This program is distributed in the hope that it will be useful,
+ *     but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *     GNU Affero General Public License for more details.
+ *
+ *     You should have received a copy of the GNU Affero General Public License
+ *     along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ */
+
+package org.briarproject.mailbox.android.ui
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.animation.AlphaAnimation
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.fragment.app.Fragment
+import dagger.hilt.android.AndroidEntryPoint
+import org.briarproject.mailbox.R
+
+@AndroidEntryPoint
+class InitFragment : Fragment() {
+
+    private lateinit var logo: ImageView
+    private lateinit var text: TextView
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?,
+    ): View? {
+        return inflater.inflate(R.layout.fragment_init, container, false)
+    }
+
+    override fun onViewCreated(v: View, savedInstanceState: Bundle?) {
+        logo = v.findViewById(R.id.logo)
+        text = v.findViewById(R.id.text)
+
+        val fadeIn = AlphaAnimation(0f, 1f)
+        fadeIn.duration = 2000
+        fadeIn.fillAfter = true
+        logo.startAnimation(fadeIn)
+        text.startAnimation(fadeIn)
+    }
+
+}
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxViewModel.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/MailboxViewModel.kt
similarity index 52%
rename from mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxViewModel.kt
rename to mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/MailboxViewModel.kt
index 48661e9defc72c33c03b49f600c9d892530d7d0b..921c1b84b270999e5ad8b413b58a9672afa58201 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxViewModel.kt
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/MailboxViewModel.kt
@@ -17,32 +17,44 @@
  *
  */
 
-package org.briarproject.mailbox.android
+package org.briarproject.mailbox.android.ui
 
 import android.app.Application
+import android.content.res.Resources
+import android.graphics.Bitmap
 import androidx.annotation.UiThread
 import androidx.lifecycle.AndroidViewModel
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
-import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.liveData
 import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flowOn
 import org.briarproject.android.dontkillmelib.DozeHelper
+import org.briarproject.mailbox.android.MailboxService
+import org.briarproject.mailbox.android.QrCodeUtils
 import org.briarproject.mailbox.core.lifecycle.LifecycleManager
 import org.briarproject.mailbox.core.lifecycle.LifecycleManager.LifecycleState
+import org.briarproject.mailbox.core.setup.QrCodeEncoder
+import org.briarproject.mailbox.core.setup.SetupComplete
 import org.briarproject.mailbox.core.setup.SetupManager
 import org.briarproject.mailbox.core.system.DozeWatchdog
+import org.briarproject.mailbox.core.tor.TorPlugin
 import javax.inject.Inject
 import kotlin.concurrent.thread
+import kotlin.math.min
 
 @HiltViewModel
 class MailboxViewModel @Inject constructor(
     private val app: Application,
     private val dozeHelper: DozeHelper,
     private val dozeWatchdog: DozeWatchdog,
-    handle: SavedStateHandle,
     private val lifecycleManager: LifecycleManager,
     private val setupManager: SetupManager,
+    private val qrCodeEncoder: QrCodeEncoder,
+    torPlugin: TorPlugin,
 ) : AndroidViewModel(app) {
 
     val needToShowDoNotKillMeFragment get() = dozeHelper.needToShowDoNotKillMeFragment(app)
@@ -50,12 +62,41 @@ class MailboxViewModel @Inject constructor(
     private val _doNotKillComplete = MutableLiveData<Boolean>()
     val doNotKillComplete: LiveData<Boolean> = _doNotKillComplete
 
-    private val _text = handle.getLiveData("text", "Hello Mailbox")
-    val text: LiveData<String> = _text
+    private val lifecycleState: StateFlow<LifecycleState> = lifecycleManager.lifecycleStateFlow
+    private val torPluginState: StateFlow<TorPlugin.State> = torPlugin.state
 
-    val lifecycleState: StateFlow<LifecycleState> = lifecycleManager.lifecycleStateFlow
+    val hasDb: LiveData<Boolean> = liveData(Dispatchers.IO) { emit(setupManager.hasDb) }
 
-    val isSetUp: Boolean get() = setupManager.hasDb
+    /**
+     * Possible values for [setupState]
+     */
+    sealed interface MailboxStartupProgress
+    class Starting(val status: String) : MailboxStartupProgress
+    class StartedSettingUp(val qrCode: Bitmap) : MailboxStartupProgress
+    object StartedSetupComplete : MailboxStartupProgress
+
+    val setupState = combine(
+        lifecycleState, torPluginState, setupManager.setupComplete
+    ) { ls, ts, sc ->
+        when {
+            ls != LifecycleState.RUNNING -> Starting(ls.name)
+            // TODO waiting for ACTIVE is better than not doing it but to fix #90 we need to listen for
+            //  upload events to the hsdirs
+            ts != TorPlugin.State.ACTIVE -> Starting(ts.name + " TOR")
+            sc == SetupComplete.FALSE -> {
+                val dm = Resources.getSystem().displayMetrics
+                val size = min(dm.widthPixels, dm.heightPixels)
+                val bitMatrix = qrCodeEncoder.getQrCodeBitMatrix(size)
+                StartedSettingUp(
+                    bitMatrix?.let { it -> QrCodeUtils.renderQrCode(it) }
+                        ?: error("The QR code bit matrix is expected to be non-null here")
+                )
+            }
+            sc == SetupComplete.TRUE -> StartedSetupComplete
+            // else means sc == SetupComplete.UNKNOWN
+            else -> error("Expected setup completion to be known at this point")
+        }
+    }.flowOn(Dispatchers.IO)
 
     @UiThread
     fun onDoNotKillComplete() {
@@ -80,8 +121,4 @@ class MailboxViewModel @Inject constructor(
 
     fun getAndResetDozeFlag() = dozeWatchdog.andResetDozeFlag
 
-    fun updateText(str: String) {
-        _text.value = str
-    }
-
 }
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/MainActivity.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/MainActivity.kt
similarity index 61%
rename from mailbox-android/src/main/java/org/briarproject/mailbox/android/MainActivity.kt
rename to mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/MainActivity.kt
index 1aad5c7f10dd0a913033919c3ab4eef732e32d22..88cabeb693100c67aac7243cbc15d2503936849c 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/MainActivity.kt
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/MainActivity.kt
@@ -17,10 +17,13 @@
  *
  */
 
-package org.briarproject.mailbox.android
+package org.briarproject.mailbox.android.ui
 
 import android.content.Intent
 import android.os.Bundle
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.ActivityResultCallback
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
 import androidx.activity.viewModels
 import androidx.appcompat.app.AlertDialog
 import androidx.appcompat.app.AppCompatActivity
@@ -29,10 +32,12 @@ import androidx.navigation.fragment.NavHostFragment
 import dagger.hilt.android.AndroidEntryPoint
 import org.briarproject.android.dontkillmelib.PowerUtils.needsDozeWhitelisting
 import org.briarproject.mailbox.R
-import org.briarproject.mailbox.android.ui.OnboardingActivity
+import org.briarproject.mailbox.android.dontkillme.DoNotKillMeFragmentDirections.actionDoNotKillMeFragmentToStartupFragment
+import org.briarproject.mailbox.android.ui.InitFragmentDirections.actionInitFragmentToDoNotKillMeFragment
+import org.briarproject.mailbox.android.ui.InitFragmentDirections.actionInitFragmentToStartupFragment
 
 @AndroidEntryPoint
-class MainActivity : AppCompatActivity() {
+class MainActivity : AppCompatActivity(), ActivityResultCallback<ActivityResult> {
 
     private val viewModel: MailboxViewModel by viewModels()
     private val nav: NavController by lazy {
@@ -41,26 +46,35 @@ class MainActivity : AppCompatActivity() {
         navHostFragment.navController
     }
 
+    private val startForResult = registerForActivityResult(StartActivityForResult(), this)
+
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContentView(R.layout.activity_main)
 
         viewModel.doNotKillComplete.observe(this) { complete ->
-            if (complete) nav.popBackStack()
+            if (complete) nav.navigate(actionDoNotKillMeFragmentToStartupFragment())
         }
 
         if (savedInstanceState == null) {
-            if (viewModel.needToShowDoNotKillMeFragment) {
-                nav.navigate(R.id.action_mainFragment_to_doNotKillMeFragment)
-            }
-            if (!viewModel.isSetUp) {
-                Intent(this, OnboardingActivity::class.java).also { i ->
-                    startActivity(i)
+            viewModel.hasDb.observe(this) { hasDb ->
+                if (!hasDb) {
+                    startForResult.launch(Intent(this, OnboardingActivity::class.java))
+                } else {
+                    nav.navigate(actionInitFragmentToStartupFragment())
                 }
             }
         }
     }
 
+    override fun onActivityResult(result: ActivityResult?) {
+        if (viewModel.needToShowDoNotKillMeFragment) {
+            nav.navigate(actionInitFragmentToDoNotKillMeFragment())
+        } else {
+            nav.navigate(actionInitFragmentToStartupFragment())
+        }
+    }
+
     override fun onResume() {
         super.onResume()
         if (needsDozeWhitelisting(this) && viewModel.getAndResetDozeFlag()) {
@@ -71,7 +85,7 @@ class MainActivity : AppCompatActivity() {
     private fun showDozeDialog() = AlertDialog.Builder(this)
         .setMessage(R.string.warning_dozed)
         .setPositiveButton(R.string.fix) { dialog, _ ->
-            nav.navigate(R.id.action_mainFragment_to_doNotKillMeFragment)
+            nav.navigate(actionInitFragmentToDoNotKillMeFragment())
             dialog.dismiss()
         }
         .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() }
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/OnboardingFragment.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/OnboardingFragment.kt
index 6d5b409f9ef9a718e546d5f16a27fea6e688e5e5..5bebab18ab67029f8390cc729dd0427bc666887c 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/OnboardingFragment.kt
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/OnboardingFragment.kt
@@ -42,6 +42,9 @@ class Onboarding0Fragment : OnboardingFragment(
     bottomButtonAction = {
         requireActivity().supportFinishAfterTransition()
     },
+    backButtonAction = {
+        requireActivity().finishAffinity()
+    },
 )
 
 class Onboarding1Fragment : OnboardingFragment(
@@ -81,6 +84,8 @@ abstract class OnboardingFragment(
     private val bottomButtonAction: OnboardingFragment.(OnboardingViewModel) -> Unit = { model ->
         model.selectPage(number - 1)
     },
+    private val backButtonAction: OnboardingFragment.(OnboardingViewModel) -> Unit =
+        bottomButtonAction,
 ) : Fragment() {
 
     private val viewModel: OnboardingViewModel by activityViewModels()
@@ -94,7 +99,7 @@ abstract class OnboardingFragment(
     // This callback will only be called when this Fragment is at least Resumed, not Started.
     private val callback = object : OnBackPressedCallback(false) {
         override fun handleOnBackPressed() {
-            bottomButtonAction(viewModel)
+            backButtonAction(viewModel)
         }
     }
 
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/QrCodeFragment.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/QrCodeFragment.kt
new file mode 100644
index 0000000000000000000000000000000000000000..42060545dde1558b766bc589d0f8b54d1d27138f
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/QrCodeFragment.kt
@@ -0,0 +1,90 @@
+/*
+ *     Briar Mailbox
+ *     Copyright (C) 2021-2022  The Briar Project
+ *
+ *     This program is free software: you can redistribute it and/or modify
+ *     it under the terms of the GNU Affero General Public License as
+ *     published by the Free Software Foundation, either version 3 of the
+ *     License, or (at your option) any later version.
+ *
+ *     This program is distributed in the hope that it will be useful,
+ *     but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *     GNU Affero General Public License for more details.
+ *
+ *     You should have received a copy of the GNU Affero General Public License
+ *     along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ */
+
+package org.briarproject.mailbox.android.ui
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.ImageView
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.fragment.findNavController
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+import org.briarproject.mailbox.R
+import org.briarproject.mailbox.android.ui.MailboxViewModel.MailboxStartupProgress
+import org.briarproject.mailbox.android.ui.MailboxViewModel.StartedSettingUp
+import org.briarproject.mailbox.android.ui.MailboxViewModel.StartedSetupComplete
+import org.briarproject.mailbox.android.ui.QrCodeFragmentDirections.actionQrCodeFragmentToSetupCompleteFragment
+
+@AndroidEntryPoint
+class QrCodeFragment : Fragment() {
+
+    private val viewModel: MailboxViewModel by activityViewModels()
+    private lateinit var qrCodeView: ImageView
+    private lateinit var buttonCancel: Button
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?,
+    ): View? {
+        return inflater.inflate(R.layout.fragment_qr, container, false)
+    }
+
+    override fun onViewCreated(v: View, savedInstanceState: Bundle?) {
+        qrCodeView = v.findViewById(R.id.qrcode)
+        buttonCancel = v.findViewById(R.id.buttonCancel)
+
+        buttonCancel.setOnClickListener {
+            viewModel.stopLifecycle()
+            requireActivity().finishAffinity()
+        }
+
+        // Start a coroutine in the lifecycle scope
+        viewLifecycleOwner.lifecycleScope.launch {
+            // repeatOnLifecycle launches the block in a new coroutine every time the
+            // lifecycle is in the STARTED state (or above) and cancels it when it's STOPPED.
+            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                // Trigger the flow and start listening for values.
+                // Note that this happens when lifecycle is STARTED and stops
+                // collecting when the lifecycle is STOPPED
+                viewModel.setupState.collect { onSetupStateChanged(it) }
+            }
+        }
+    }
+
+    private fun onSetupStateChanged(setupComplete: MailboxStartupProgress) {
+        when (setupComplete) {
+            is StartedSettingUp -> qrCodeView.setImageBitmap(setupComplete.qrCode)
+            is StartedSetupComplete -> findNavController().navigate(
+                actionQrCodeFragmentToSetupCompleteFragment()
+            )
+            else -> error("Unexpected setup state: $setupComplete")
+        }
+    }
+
+}
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/SetupCompleteFragment.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/SetupCompleteFragment.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c5867be9a787403a125b63d26e43c50c4bc850ed
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/SetupCompleteFragment.kt
@@ -0,0 +1,55 @@
+/*
+ *     Briar Mailbox
+ *     Copyright (C) 2021-2022  The Briar Project
+ *
+ *     This program is free software: you can redistribute it and/or modify
+ *     it under the terms of the GNU Affero General Public License as
+ *     published by the Free Software Foundation, either version 3 of the
+ *     License, or (at your option) any later version.
+ *
+ *     This program is distributed in the hope that it will be useful,
+ *     but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *     GNU Affero General Public License for more details.
+ *
+ *     You should have received a copy of the GNU Affero General Public License
+ *     along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ */
+
+package org.briarproject.mailbox.android.ui
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.fragment.findNavController
+import dagger.hilt.android.AndroidEntryPoint
+import org.briarproject.mailbox.R
+import org.briarproject.mailbox.android.ui.SetupCompleteFragmentDirections.actionSetupCompleteFragmentToStatusFragment
+
+@AndroidEntryPoint
+class SetupCompleteFragment : Fragment() {
+
+    private val viewModel: MailboxViewModel by activityViewModels()
+    private lateinit var button: Button
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?,
+    ): View? {
+        return inflater.inflate(R.layout.fragment_setup_complete, container, false)
+    }
+
+    override fun onViewCreated(v: View, savedInstanceState: Bundle?) {
+        button = v.findViewById(R.id.button)
+        button.setOnClickListener {
+            findNavController().navigate(actionSetupCompleteFragmentToStatusFragment())
+        }
+    }
+
+}
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/MainFragment.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/StartupFragment.kt
similarity index 54%
rename from mailbox-android/src/main/java/org/briarproject/mailbox/android/MainFragment.kt
rename to mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/StartupFragment.kt
index 6b5dab13b79a37f93aa46363e5f9f922b30209e4..a707919ad61d5b03afe8e9afe7566ac7a198c454 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/MainFragment.kt
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/StartupFragment.kt
@@ -17,88 +17,70 @@
  *
  */
 
-package org.briarproject.mailbox.android
+package org.briarproject.mailbox.android.ui
 
 import android.os.Bundle
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
-import android.widget.Button
 import android.widget.TextView
 import androidx.fragment.app.Fragment
 import androidx.fragment.app.activityViewModels
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.lifecycleScope
 import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.fragment.findNavController
 import dagger.hilt.android.AndroidEntryPoint
 import kotlinx.coroutines.flow.collect
 import kotlinx.coroutines.launch
 import org.briarproject.mailbox.R
-import org.briarproject.mailbox.core.lifecycle.LifecycleManager
+import org.briarproject.mailbox.android.ui.MailboxViewModel.MailboxStartupProgress
+import org.briarproject.mailbox.android.ui.MailboxViewModel.StartedSettingUp
+import org.briarproject.mailbox.android.ui.MailboxViewModel.Starting
+import org.briarproject.mailbox.android.ui.StartupFragmentDirections.actionStartupFragmentToQrCodeFragment
+import org.briarproject.mailbox.android.ui.StartupFragmentDirections.actionStartupFragmentToStatusFragment
 
 @AndroidEntryPoint
-class MainFragment : Fragment() {
+class StartupFragment : Fragment() {
 
     private val viewModel: MailboxViewModel by activityViewModels()
     private lateinit var statusTextView: TextView
-    private lateinit var startStopButton: Button
-    private lateinit var wipeButton: Button
 
     override fun onCreateView(
         inflater: LayoutInflater,
         container: ViewGroup?,
         savedInstanceState: Bundle?,
     ): View? {
-        return inflater.inflate(R.layout.fragment_main, container, false)
+        return inflater.inflate(R.layout.fragment_startup, container, false)
     }
 
     override fun onViewCreated(v: View, savedInstanceState: Bundle?) {
-        val textView = v.findViewById<TextView>(R.id.text)
-        val button = v.findViewById<Button>(R.id.button)
         statusTextView = v.findViewById(R.id.statusTextView)
-        startStopButton = v.findViewById(R.id.startStopButton)
-        wipeButton = v.findViewById(R.id.wipeButton)
-
-        button.setOnClickListener {
-            viewModel.updateText("Tested")
-        }
 
         // Start a coroutine in the lifecycle scope
-        lifecycleScope.launch {
+        viewLifecycleOwner.lifecycleScope.launch {
             // repeatOnLifecycle launches the block in a new coroutine every time the
             // lifecycle is in the STARTED state (or above) and cancels it when it's STOPPED.
-            repeatOnLifecycle(Lifecycle.State.STARTED) {
+            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                 // Trigger the flow and start listening for values.
                 // Note that this happens when lifecycle is STARTED and stops
                 // collecting when the lifecycle is STOPPED
-                viewModel.lifecycleState.collect { onLifecycleStateChanged(it) }
+                viewModel.setupState.collect { onSetupStateChanged(it) }
             }
         }
 
-        viewModel.text.observe(viewLifecycleOwner, { text ->
-            textView.text = text
-        })
+        viewModel.startLifecycle()
     }
 
-    private fun onLifecycleStateChanged(state: LifecycleManager.LifecycleState) = when (state) {
-        LifecycleManager.LifecycleState.NOT_STARTED -> {
-            statusTextView.text = state.name
-            startStopButton.setText(R.string.start)
-            startStopButton.setOnClickListener { viewModel.startLifecycle() }
-            startStopButton.isEnabled = true
-        }
-        LifecycleManager.LifecycleState.RUNNING -> {
-            statusTextView.text = state.name
-            startStopButton.setText(R.string.stop)
-            startStopButton.setOnClickListener { viewModel.stopLifecycle() }
-            wipeButton.setOnClickListener { viewModel.wipe() }
-            startStopButton.isEnabled = true
-            wipeButton.isEnabled = true
-        }
-        else -> {
-            statusTextView.text = state.name
-            startStopButton.isEnabled = false
-            wipeButton.isEnabled = false
+    private fun onSetupStateChanged(state: MailboxStartupProgress) {
+        when (state) {
+            is Starting -> statusTextView.text = state.status
+            is StartedSettingUp -> findNavController().navigate(
+                actionStartupFragmentToQrCodeFragment()
+            )
+            is MailboxViewModel.StartedSetupComplete -> findNavController().navigate(
+                actionStartupFragmentToStatusFragment()
+            )
         }
     }
 
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/StatusFragment.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/StatusFragment.kt
new file mode 100644
index 0000000000000000000000000000000000000000..3aa070662bc24cb51290c8bcc78647b5fc4982ad
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ui/StatusFragment.kt
@@ -0,0 +1,53 @@
+/*
+ *     Briar Mailbox
+ *     Copyright (C) 2021-2022  The Briar Project
+ *
+ *     This program is free software: you can redistribute it and/or modify
+ *     it under the terms of the GNU Affero General Public License as
+ *     published by the Free Software Foundation, either version 3 of the
+ *     License, or (at your option) any later version.
+ *
+ *     This program is distributed in the hope that it will be useful,
+ *     but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *     GNU Affero General Public License for more details.
+ *
+ *     You should have received a copy of the GNU Affero General Public License
+ *     along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ */
+
+package org.briarproject.mailbox.android.ui
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import dagger.hilt.android.AndroidEntryPoint
+import org.briarproject.mailbox.R
+
+@AndroidEntryPoint
+class StatusFragment : Fragment() {
+
+    private val viewModel: MailboxViewModel by activityViewModels()
+    private lateinit var buttonStop: Button
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?,
+    ): View? {
+        return inflater.inflate(R.layout.fragment_status, container, false)
+    }
+
+    override fun onViewCreated(v: View, savedInstanceState: Bundle?) {
+        buttonStop = v.findViewById(R.id.buttonStop)
+        buttonStop.setOnClickListener {
+            viewModel.stopLifecycle()
+        }
+    }
+
+}
diff --git a/mailbox-android/src/main/res/drawable/ic_mailbox.xml b/mailbox-android/src/main/res/drawable/ic_mailbox.xml
new file mode 100644
index 0000000000000000000000000000000000000000..12df38ef8607c88bb61628f6aa2a53e2ba8f8af9
--- /dev/null
+++ b/mailbox-android/src/main/res/drawable/ic_mailbox.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="30dp"
+    android:height="34dp"
+    android:viewportWidth="30"
+    android:viewportHeight="34">
+  <path
+      android:pathData="M3.334,0C2.4499,-0 1.6017,0.3579 0.9766,0.9941C0.3514,1.6304 0,2.4928 0,3.3926L0,25.1055C0,26.0053 0.3514,26.8696 0.9766,27.5059C1.6017,28.1421 2.4499,28.498 3.334,28.498L21.4902,28.498L27.5391,33.6426C28.5157,34.4732 30,33.766 30,32.4707L30,3.3926C30,2.4928 29.6485,1.6304 29.0234,0.9941C28.3983,0.3579 27.55,0 26.666,0L3.334,0zM5.334,5.4277L24.666,5.4277L24.666,15.0332L17.9121,15.0332L17.9121,18.3555L20.3828,18.3555C20.8283,18.3555 21.0512,18.904 20.7363,19.2246L15.4102,24.6445C15.2149,24.8432 14.8984,24.8432 14.7031,24.6445L9.377,19.2246C9.062,18.904 9.285,18.3555 9.7305,18.3555L12.2012,18.3555L12.2012,15.0332L5.334,15.0332L5.334,5.4277z"
+      android:fillColor="#ffffff"/>
+</vector>
diff --git a/mailbox-android/src/main/res/drawable/ic_square.xml b/mailbox-android/src/main/res/drawable/ic_square.xml
new file mode 100644
index 0000000000000000000000000000000000000000..07e62993e919890cb5481604b5891c6a72d95f42
--- /dev/null
+++ b/mailbox-android/src/main/res/drawable/ic_square.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="?attr/colorControlNormal"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M0,0L0,24 24,24 24,0z" />
+</vector>
diff --git a/mailbox-android/src/main/res/layout-land/fragment_qr.xml b/mailbox-android/src/main/res/layout-land/fragment_qr.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b4b86f48001e51a30aaa62192c271b13c9435758
--- /dev/null
+++ b/mailbox-android/src/main/res/layout-land/fragment_qr.xml
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".android.ui.MainActivity">
+
+    <TextView
+        android:id="@+id/headline"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:paddingHorizontal="16dp"
+        android:gravity="center_horizontal"
+        android:text="@string/link_title"
+        android:textAlignment="center"
+        android:textSize="32sp"
+        app:layout_constraintBottom_toTopOf="@id/description"
+        app:layout_constraintEnd_toStartOf="@id/card"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent" />
+
+    <TextView
+        android:id="@+id/description"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:paddingHorizontal="16dp"
+        android:gravity="center_horizontal"
+        android:text="@string/link_description"
+        android:textAlignment="center"
+        android:textSize="24sp"
+        app:layout_constraintBottom_toTopOf="@id/buttonCancel"
+        app:layout_constraintEnd_toStartOf="@id/card"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/headline" />
+
+    <com.google.android.material.button.MaterialButton
+        android:id="@+id/buttonCancel"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginHorizontal="16dp"
+        android:layout_marginBottom="16dp"
+        android:text="@string/link_cancel"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toStartOf="@id/card"
+        app:layout_constraintStart_toStartOf="parent" />
+
+    <androidx.cardview.widget.CardView
+        android:id="@+id/card"
+        android:layout_width="wrap_content"
+        android:layout_height="match_parent"
+        android:layout_margin="16dp"
+        app:cardCornerRadius="32dp"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toEndOf="@id/headline"
+        app:layout_constraintTop_toTopOf="parent"
+        app:srcCompat="@drawable/ic_square">
+
+        <ImageView
+            android:id="@+id/qrcode"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:adjustViewBounds="true"
+            app:srcCompat="@drawable/ic_square" />
+
+    </androidx.cardview.widget.CardView>
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/mailbox-android/src/main/res/layout/activity_main.xml b/mailbox-android/src/main/res/layout/activity_main.xml
index d8d6ae61cc5fef38779d2d922b3e48d915bd9833..40a9283dad798a8204bef92907b566978e8c1119 100644
--- a/mailbox-android/src/main/res/layout/activity_main.xml
+++ b/mailbox-android/src/main/res/layout/activity_main.xml
@@ -8,4 +8,4 @@
     android:layout_height="match_parent"
     app:defaultNavHost="true"
     app:navGraph="@navigation/nav_main"
-    tools:context=".android.MainActivity" />
+    tools:context=".android.ui.MainActivity" />
diff --git a/mailbox-android/src/main/res/layout/fragment_init.xml b/mailbox-android/src/main/res/layout/fragment_init.xml
new file mode 100644
index 0000000000000000000000000000000000000000..3ca0fe5e6cd8ae20079f2d146573a90a40ec41c0
--- /dev/null
+++ b/mailbox-android/src/main/res/layout/fragment_init.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <ImageView
+        android:id="@+id/logo"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        android:layout_margin="32dp"
+        android:visibility="visible"
+        app:layout_constraintBottom_toTopOf="@+id/text"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:srcCompat="@drawable/ic_mailbox"
+        app:tint="@color/briar_green" />
+
+    <TextView
+        android:id="@+id/text"
+        android:layout_width="wrap_content"
+        android:layout_height="0dp"
+        android:text="@string/app_name"
+        android:textSize="32sp"
+        app:layout_constraintBottom_toTopOf="@+id/barrier"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/logo" />
+
+    <androidx.constraintlayout.widget.Barrier
+        android:id="@+id/barrier"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:barrierDirection="top"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintVertical_bias="0.8" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/mailbox-android/src/main/res/layout/fragment_main.xml b/mailbox-android/src/main/res/layout/fragment_main.xml
deleted file mode 100644
index b931cb7101102808ebe76d929eb5a70c41df5910..0000000000000000000000000000000000000000
--- a/mailbox-android/src/main/res/layout/fragment_main.xml
+++ /dev/null
@@ -1,64 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    tools:context=".android.MainActivity">
-
-    <TextView
-        android:id="@+id/text"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:text="Hello World!"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintLeft_toLeftOf="parent"
-        app:layout_constraintRight_toRightOf="parent"
-        app:layout_constraintTop_toTopOf="parent" />
-
-    <Button
-        android:id="@+id/button"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_marginTop="16dp"
-        android:text="Test"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@+id/text" />
-
-    <TextView
-        android:id="@+id/statusTextView"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:layout_margin="16dp"
-        android:gravity="center"
-        app:layout_constraintBottom_toTopOf="@+id/startStopButton"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@+id/button"
-        app:layout_constraintVertical_bias="1.0"
-        tools:text="STOPPED" />
-
-    <Button
-        android:id="@+id/startStopButton"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_margin="16dp"
-        android:text="@string/start"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintBottom_toTopOf="@+id/wipeButton"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent" />
-
-    <Button
-        android:id="@+id/wipeButton"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_margin="16dp"
-        android:enabled="false"
-        android:text="@string/wipe"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/mailbox-android/src/main/res/layout/fragment_qr.xml b/mailbox-android/src/main/res/layout/fragment_qr.xml
new file mode 100644
index 0000000000000000000000000000000000000000..35cb339058b7ecab93eb11ef20292a8904cbe7ef
--- /dev/null
+++ b/mailbox-android/src/main/res/layout/fragment_qr.xml
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".android.ui.MainActivity">
+
+    <TextView
+        android:id="@+id/headline"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:paddingHorizontal="16dp"
+        android:text="@string/link_title"
+        android:textSize="32sp"
+        app:layout_constraintBottom_toTopOf="@id/description"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent" />
+
+    <TextView
+        android:id="@+id/description"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:gravity="center_horizontal"
+        android:paddingHorizontal="16dp"
+        android:text="@string/link_description"
+        android:textAlignment="center"
+        android:textSize="24sp"
+        app:layout_constraintBottom_toTopOf="@id/card"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="@id/headline" />
+
+    <androidx.cardview.widget.CardView
+        android:id="@+id/card"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_margin="16dp"
+        app:cardCornerRadius="32dp"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="@id/buttonCancel"
+        app:layout_constraintTop_toBottomOf="@id/description"
+        app:layout_constraintVertical_bias="1.0"
+        app:srcCompat="@drawable/ic_square">
+
+        <ImageView
+            android:id="@+id/qrcode"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:adjustViewBounds="true"
+            app:srcCompat="@drawable/ic_square" />
+
+    </androidx.cardview.widget.CardView>
+
+    <com.google.android.material.button.MaterialButton
+        android:id="@+id/buttonCancel"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginHorizontal="16dp"
+        android:layout_marginBottom="16dp"
+        android:text="@string/link_cancel"
+        app:layout_constraintBottom_toBottomOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/mailbox-android/src/main/res/layout/fragment_setup_complete.xml b/mailbox-android/src/main/res/layout/fragment_setup_complete.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b18b334eb30afd2552e67d743422197dd5f8d6a3
--- /dev/null
+++ b/mailbox-android/src/main/res/layout/fragment_setup_complete.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".android.ui.MainActivity">
+
+    <TextView
+        android:id="@+id/headline"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_margin="16dp"
+        android:text="Connected"
+        android:textSize="32dp"
+        app:layout_constraintBottom_toTopOf="@+id/button"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent" />
+
+    <Button
+        android:id="@+id/button"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_margin="16dp"
+        android:text="Finish"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/mailbox-android/src/main/res/layout/fragment_startup.xml b/mailbox-android/src/main/res/layout/fragment_startup.xml
new file mode 100644
index 0000000000000000000000000000000000000000..379e4d9d1f58eb342eb2008e9bff04e5df5015f4
--- /dev/null
+++ b/mailbox-android/src/main/res/layout/fragment_startup.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".android.ui.MainActivity">
+
+    <com.google.android.material.progressindicator.CircularProgressIndicator
+        android:id="@+id/progress"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:indeterminate="true"
+        app:indicatorColor="?colorSecondary"
+        app:indicatorSize="256dp"
+        app:layout_constraintBottom_toTopOf="@id/statusTextView"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:trackThickness="12dp" />
+
+    <TextView
+        android:id="@+id/statusTextView"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_margin="16dp"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/progress"
+        tools:text="STOPPED" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/mailbox-android/src/main/res/layout/fragment_status.xml b/mailbox-android/src/main/res/layout/fragment_status.xml
new file mode 100644
index 0000000000000000000000000000000000000000..3a9c852f9b1cd48ddf363c40f842ec1629a705fe
--- /dev/null
+++ b/mailbox-android/src/main/res/layout/fragment_status.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".android.ui.MainActivity">
+
+    <TextView
+        android:id="@+id/headline"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_margin="16dp"
+        android:text="Mailbox is running"
+        android:textSize="32dp"
+        app:layout_constraintBottom_toTopOf="@+id/buttonStop"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent" />
+
+    <Button
+        android:text="Stop"
+        android:id="@+id/buttonStop"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/headline"
+        app:layout_constraintBottom_toBottomOf="parent"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/mailbox-android/src/main/res/navigation/nav_main.xml b/mailbox-android/src/main/res/navigation/nav_main.xml
index 3f7bee7ff9790e6e282d9838aff503277fe21527..45e8871b281b48a3325aca97c869c5e5284815a4 100644
--- a/mailbox-android/src/main/res/navigation/nav_main.xml
+++ b/mailbox-android/src/main/res/navigation/nav_main.xml
@@ -2,18 +2,70 @@
 <navigation xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
     android:id="@+id/nav_onboarding"
-    app:startDestination="@id/mainFragment">
+    app:startDestination="@id/initFragment">
 
     <fragment
-        android:id="@+id/mainFragment"
-        android:name="org.briarproject.mailbox.android.MainFragment"
-        android:label="MainFragment">
+        android:id="@+id/initFragment"
+        android:name="org.briarproject.mailbox.android.ui.InitFragment"
+        android:label="InitFragment">
         <action
-            android:id="@+id/action_mainFragment_to_doNotKillMeFragment"
-            app:destination="@id/doNotKillMeFragment" />
+            android:id="@+id/action_initFragment_to_doNotKillMeFragment"
+            app:destination="@id/doNotKillMeFragment"
+            app:popUpTo="@id/initFragment"
+            app:popUpToInclusive="true" />
+        <action
+            android:id="@+id/action_initFragment_to_startupFragment"
+            app:destination="@id/startupFragment"
+            app:popUpTo="@id/initFragment"
+            app:popUpToInclusive="true" />
     </fragment>
     <fragment
         android:id="@+id/doNotKillMeFragment"
         android:name="org.briarproject.mailbox.android.dontkillme.DoNotKillMeFragment"
-        android:label="DoNotKillMeFragment" />
+        android:label="DoNotKillMeFragment">
+        <action
+            android:id="@+id/action_doNotKillMeFragment_to_startupFragment"
+            app:destination="@id/startupFragment"
+            app:popUpTo="@id/doNotKillMeFragment"
+            app:popUpToInclusive="true" />
+    </fragment>
+    <fragment
+        android:id="@+id/startupFragment"
+        android:name="org.briarproject.mailbox.android.ui.StartupFragment"
+        android:label="StartupFragment">
+        <action
+            android:id="@+id/action_startupFragment_to_qrCodeFragment"
+            app:destination="@id/qrCodeFragment"
+            app:popUpTo="@id/startupFragment"
+            app:popUpToInclusive="true" />
+        <action
+            android:id="@+id/action_startupFragment_to_statusFragment"
+            app:destination="@id/statusFragment"
+            app:popUpTo="@id/startupFragment"
+            app:popUpToInclusive="true" />
+    </fragment>
+    <fragment
+        android:id="@+id/qrCodeFragment"
+        android:name="org.briarproject.mailbox.android.ui.QrCodeFragment"
+        android:label="QrCodeFragment">
+        <action
+            android:id="@+id/action_qrCodeFragment_to_setupCompleteFragment"
+            app:destination="@id/setupCompleteFragment"
+            app:popUpTo="@id/qrCodeFragment"
+            app:popUpToInclusive="true" />
+    </fragment>
+    <fragment
+        android:id="@+id/setupCompleteFragment"
+        android:name="org.briarproject.mailbox.android.ui.SetupCompleteFragment"
+        android:label="SetupCompleteFragment">
+        <action
+            android:id="@+id/action_setupCompleteFragment_to_statusFragment"
+            app:destination="@id/statusFragment"
+            app:popUpTo="@id/setupCompleteFragment"
+            app:popUpToInclusive="true" />
+    </fragment>
+    <fragment
+        android:id="@+id/statusFragment"
+        android:name="org.briarproject.mailbox.android.ui.StatusFragment"
+        android:label="StatusFragment" />
 </navigation>
diff --git a/mailbox-android/src/main/res/values/strings.xml b/mailbox-android/src/main/res/values/strings.xml
index 094a73711833657cb9952cb484e07b0f5cf1f9bd..f3b092575b8950603796f73b313a2c1c56059edb 100644
--- a/mailbox-android/src/main/res/values/strings.xml
+++ b/mailbox-android/src/main/res/values/strings.xml
@@ -24,4 +24,8 @@
     <string name="warning_dozed">Briar Mailbox was unable to run in the background</string>
     <string name="fix">Fix</string>
     <string name="cancel">Cancel</string>
+
+    <string name="link_title">Link via QR code</string>
+    <string name="link_description">Scan this QR code with Briar</string>
+    <string name="link_cancel">Cancel Setup</string>
 </resources>
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/SetupManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/SetupManager.kt
index faccb6fdde94d89dff9cfccee8c0e0901dc98006..693c3577a701d047de0b4da87c4782680db40636 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/SetupManager.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/SetupManager.kt
@@ -23,14 +23,21 @@ import io.ktor.application.ApplicationCall
 import io.ktor.auth.principal
 import io.ktor.http.HttpStatusCode
 import io.ktor.response.respond
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
 import org.briarproject.mailbox.core.db.DbException
 import org.briarproject.mailbox.core.db.Transaction
 import org.briarproject.mailbox.core.files.FileManager
+import org.briarproject.mailbox.core.lifecycle.LifecycleManager.LifecycleState
+import org.briarproject.mailbox.core.lifecycle.LifecycleManager.LifecycleState.RUNNING
 import org.briarproject.mailbox.core.lifecycle.LifecycleManager.OpenDatabaseHook
 import org.briarproject.mailbox.core.server.AuthException
 import org.briarproject.mailbox.core.server.AuthManager
 import org.briarproject.mailbox.core.settings.Settings
 import org.briarproject.mailbox.core.settings.SettingsManager
+import org.briarproject.mailbox.core.setup.SetupComplete.FALSE
+import org.briarproject.mailbox.core.setup.SetupComplete.TRUE
+import org.briarproject.mailbox.core.setup.SetupComplete.UNKNOWN
 import org.briarproject.mailbox.core.system.RandomIdManager
 import javax.inject.Inject
 
@@ -38,6 +45,12 @@ private const val SETTINGS_NAMESPACE_OWNER = "owner"
 private const val SETTINGS_SETUP_TOKEN = "setupToken"
 private const val SETTINGS_OWNER_TOKEN = "ownerToken"
 
+enum class SetupComplete {
+    UNKNOWN,
+    FALSE,
+    TRUE,
+}
+
 interface SetupManager : OpenDatabaseHook {
     /**
      * True if a database has been setup.
@@ -47,6 +60,12 @@ interface SetupManager : OpenDatabaseHook {
      */
     val hasDb: Boolean
 
+    /**
+     * This is UNKNOWN initially and will be set to TRUE or FALSE while the database is opened.
+     * It is safe to assume a value != [UNKNOWN] when the [LifecycleState] is [RUNNING].
+     */
+    val setupComplete: StateFlow<SetupComplete>
+
     @Throws(DbException::class)
     fun setToken(setupToken: String?, ownerToken: String?)
 
@@ -65,6 +84,9 @@ class SetupManagerImpl @Inject constructor(
 
     override val hasDb: Boolean get() = fileManager.hasDbFile()
 
+    private val _setupComplete = MutableStateFlow(UNKNOWN)
+    override val setupComplete: StateFlow<SetupComplete> = _setupComplete
+
     @Throws(DbException::class)
     override fun onDatabaseOpened(txn: Transaction) {
         val settings = settingsManager.getSettings(txn, SETTINGS_NAMESPACE_OWNER)
@@ -74,6 +96,9 @@ class SetupManagerImpl @Inject constructor(
         if (setupToken == null && ownerToken == null) {
             settings[SETTINGS_SETUP_TOKEN] = randomIdManager.getNewRandomId()
             settingsManager.mergeSettings(txn, settings, SETTINGS_NAMESPACE_OWNER)
+            _setupComplete.value = FALSE
+        } else {
+            _setupComplete.value = if (ownerToken != null) TRUE else FALSE
         }
     }
 
@@ -90,6 +115,7 @@ class SetupManagerImpl @Inject constructor(
         if (ownerToken != null) randomIdManager.assertIsRandomId(ownerToken)
         settings[SETTINGS_OWNER_TOKEN] = ownerToken
         settingsManager.mergeSettings(settings, SETTINGS_NAMESPACE_OWNER)
+        _setupComplete.value = TRUE
     }
 
     @Throws(DbException::class)