From 367e8aab0c63071374bfb2d374f7c14cd928c01c Mon Sep 17 00:00:00 2001
From: Torsten Grote <t@grobox.de>
Date: Fri, 3 Feb 2023 15:19:35 -0300
Subject: [PATCH] Add MailboxScreen for pairing with error dialogs

---
 .../desktop/mailbox/MailboxErrorDialog.kt     |  99 +++++++++++
 .../briar/desktop/mailbox/MailboxScreen.kt    |  54 ++++++
 .../desktop/mailbox/MailboxSetupScreen.kt     | 113 ++++++++++++
 .../briar/desktop/mailbox/MailboxViewModel.kt | 162 ++++++++++++++++++
 .../briar/desktop/navigation/BriarSidebar.kt  |   4 +-
 .../briar/desktop/ui/MainScreen.kt            |   2 +
 .../briarproject/briar/desktop/ui/UiMode.kt   |   2 +
 .../desktop/viewmodel/ViewModelModule.kt      |   6 +
 .../resources/strings/BriarDesktop.properties |  17 ++
 9 files changed, 457 insertions(+), 2 deletions(-)
 create mode 100644 briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/mailbox/MailboxErrorDialog.kt
 create mode 100644 briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/mailbox/MailboxScreen.kt
 create mode 100644 briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/mailbox/MailboxSetupScreen.kt
 create mode 100644 briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/mailbox/MailboxViewModel.kt

diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/mailbox/MailboxErrorDialog.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/mailbox/MailboxErrorDialog.kt
new file mode 100644
index 0000000000..6f64ae07cc
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/mailbox/MailboxErrorDialog.kt
@@ -0,0 +1,99 @@
+/*
+ * Briar Desktop
+ * 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.briar.desktop.mailbox
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.Button
+import androidx.compose.material.Scaffold
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.material.rememberScaffoldState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.WindowPosition
+import androidx.compose.ui.window.rememberDialogState
+import org.briarproject.bramble.api.mailbox.MailboxPairingState
+import org.briarproject.briar.desktop.mailbox.MailboxPairingUiState.OfflineWhenPairing
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+import org.briarproject.briar.desktop.utils.PreviewUtils.preview
+import java.awt.Dimension
+
+fun main() = preview {
+    val visible = mutableStateOf(true)
+    MailboxErrorDialog(OfflineWhenPairing, visible.value) { visible.value = false }
+}
+
+@Composable
+fun MailboxErrorDialog(
+    state: MailboxPairingUiState,
+    visible: Boolean,
+    onDismissed: () -> Unit,
+) {
+    if (!visible) return
+    Dialog(
+        title = i18n("mailbox.setup.error.title"),
+        onCloseRequest = onDismissed,
+        state = rememberDialogState(
+            position = WindowPosition(Alignment.Center),
+        ),
+    ) {
+        window.minimumSize = Dimension(360, 180)
+        val scaffoldState = rememberScaffoldState()
+        Surface {
+            Scaffold(
+                modifier = Modifier
+                    .padding(horizontal = 24.dp)
+                    .padding(top = 24.dp, bottom = 12.dp),
+                scaffoldState = scaffoldState,
+                content = {
+                    Text(
+                        text = state.getError(),
+                    )
+                },
+                bottomBar = {
+                    Button(
+                        onClick = onDismissed,
+                        modifier = Modifier.padding(start = 8.dp)
+                    ) {
+                        Text(i18n("ok"))
+                    }
+                },
+            )
+        }
+    }
+}
+
+private fun MailboxPairingUiState.getError(): String = when (this) {
+    is OfflineWhenPairing -> i18n("mailbox.setup.offline_error_title") + "\n\n" +
+        i18n("mailbox.setup.offline_error_description")
+    is MailboxPairingUiState.Pairing -> when (val s = pairingState) {
+        is MailboxPairingState.InvalidQrCode -> i18n("mailbox.setup.link.error")
+        is MailboxPairingState.MailboxAlreadyPaired -> i18n("mailbox.setup.already_paired_title") +
+            "\n\n" + i18n("mailbox.setup.already_paired_description")
+        is MailboxPairingState.ConnectionError -> i18n("mailbox.setup.io_error_title") +
+            "\n\n" + i18n("mailbox.setup.io_error_description")
+        is MailboxPairingState.UnexpectedError -> i18n("mailbox.setup.assertion_error_description")
+        else -> error("Unhandled pairing state: ${s::class.simpleName}")
+    }
+    else -> error("Unhandled state: ${this::class.simpleName}")
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/mailbox/MailboxScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/mailbox/MailboxScreen.kt
new file mode 100644
index 0000000000..677b3ae39e
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/mailbox/MailboxScreen.kt
@@ -0,0 +1,54 @@
+/*
+ * Briar Desktop
+ * 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.briar.desktop.mailbox
+
+import androidx.compose.runtime.Composable
+import org.briarproject.bramble.api.mailbox.MailboxPairingState.ConnectionError
+import org.briarproject.bramble.api.mailbox.MailboxPairingState.InvalidQrCode
+import org.briarproject.bramble.api.mailbox.MailboxPairingState.MailboxAlreadyPaired
+import org.briarproject.bramble.api.mailbox.MailboxPairingState.Paired
+import org.briarproject.bramble.api.mailbox.MailboxPairingState.Pending
+import org.briarproject.bramble.api.mailbox.MailboxPairingState.UnexpectedError
+import org.briarproject.briar.desktop.mailbox.MailboxPairingUiState.IsPaired
+import org.briarproject.briar.desktop.mailbox.MailboxPairingUiState.NotSetup
+import org.briarproject.briar.desktop.mailbox.MailboxPairingUiState.OfflineWhenPairing
+import org.briarproject.briar.desktop.mailbox.MailboxPairingUiState.Pairing
+import org.briarproject.briar.desktop.mailbox.MailboxPairingUiState.Unknown
+import org.briarproject.briar.desktop.mailbox.MailboxPairingUiState.WasUnpaired
+import org.briarproject.briar.desktop.ui.Loader
+import org.briarproject.briar.desktop.ui.UiPlaceholder
+import org.briarproject.briar.desktop.viewmodel.viewModel
+
+@Composable
+fun MailboxScreen(viewModel: MailboxViewModel = viewModel()) {
+    when (val state = viewModel.pairingState.value) {
+        Unknown -> Loader()
+        NotSetup -> MailboxSetupScreen(viewModel, false)
+        is Pairing -> when (val pairingState = state.pairingState) {
+            is Pending -> MailboxSetupScreen(viewModel, false)
+            is InvalidQrCode, is MailboxAlreadyPaired, is ConnectionError, is UnexpectedError -> {
+                MailboxSetupScreen(viewModel, true)
+            }
+            is Paired -> UiPlaceholder()
+        }
+        OfflineWhenPairing -> MailboxSetupScreen(viewModel, true)
+        is IsPaired -> UiPlaceholder()
+        is WasUnpaired -> UiPlaceholder()
+    }
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/mailbox/MailboxSetupScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/mailbox/MailboxSetupScreen.kt
new file mode 100644
index 0000000000..858727f0c7
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/mailbox/MailboxSetupScreen.kt
@@ -0,0 +1,113 @@
+/*
+ * Briar Desktop
+ * Copyright (C) 2023 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.briar.desktop.mailbox
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.Button
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.OutlinedTextField
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Alignment.Companion.End
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import org.briarproject.bramble.api.FormatException
+import org.briarproject.briar.desktop.mailbox.MailboxPairingUiState.NotSetup
+import org.briarproject.briar.desktop.ui.Constants.DIALOG_WIDTH
+import org.briarproject.briar.desktop.ui.HorizontalDivider
+import org.briarproject.briar.desktop.ui.VerticallyScrollableArea
+import org.briarproject.briar.desktop.utils.AccessibilityUtils.description
+import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
+
+@Composable
+fun MailboxSetupScreen(viewModel: MailboxViewModel, showError: Boolean) {
+    MailboxErrorDialog(
+        state = viewModel.pairingState.value,
+        visible = showError,
+    ) {
+        viewModel.onPairingErrorSeen()
+    }
+
+    VerticallyScrollableArea {
+        Column(
+            verticalArrangement = Arrangement.spacedBy(16.dp),
+            horizontalAlignment = CenterHorizontally,
+            modifier = Modifier.padding(16.dp).fillMaxSize(),
+        ) {
+            Text(
+                text = i18n("mailbox.setup.intro"),
+                modifier = Modifier.widthIn(max = DIALOG_WIDTH),
+            )
+            Text(
+                text = i18n("mailbox.setup.download"),
+                modifier = Modifier.widthIn(max = DIALOG_WIDTH).padding(top = 16.dp),
+            )
+            HorizontalDivider(Modifier.widthIn(max = DIALOG_WIDTH * 2))
+            Text(
+                text = i18n("mailbox.setup.link"),
+                modifier = Modifier.widthIn(max = DIALOG_WIDTH),
+            )
+
+            val isInvalid = rememberSaveable { mutableStateOf(false) }
+            val onNameChanged = { changedName: String ->
+                viewModel.onPairingLinkChanged(changedName)
+                isInvalid.value = false
+            }
+            val onOkButtonClicked = {
+                try {
+                    viewModel.pairMailbox(viewModel.pairingLink.value)
+                } catch (e: FormatException) {
+                    isInvalid.value = true
+                }
+            }
+            Column(Modifier.widthIn(max = DIALOG_WIDTH * 2)) {
+                OutlinedTextField(
+                    value = viewModel.pairingLink.value,
+                    onValueChange = onNameChanged,
+                    label = { Text(i18n("mailbox.setup.hint")) },
+                    keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+                    onEnter = onOkButtonClicked,
+                    isError = isInvalid.value,
+                    errorMessage = i18n("mailbox.setup.link.error"),
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .description(i18n("mailbox.setup.hint")),
+                )
+                if (viewModel.pairingState.value is NotSetup) Button(
+                    onClick = onOkButtonClicked,
+                    modifier = Modifier.align(End)
+                ) {
+                    Text(i18n("mailbox.setup.button"))
+                } else {
+                    CircularProgressIndicator(modifier = Modifier.align(End))
+                }
+            }
+        }
+    }
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/mailbox/MailboxViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/mailbox/MailboxViewModel.kt
new file mode 100644
index 0000000000..bbf739b8e8
--- /dev/null
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/mailbox/MailboxViewModel.kt
@@ -0,0 +1,162 @@
+/*
+ * Briar Desktop
+ * 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.briar.desktop.mailbox
+
+import androidx.compose.runtime.mutableStateOf
+import mu.KotlinLogging
+import org.briarproject.bramble.api.Consumer
+import org.briarproject.bramble.api.db.TransactionManager
+import org.briarproject.bramble.api.event.Event
+import org.briarproject.bramble.api.event.EventBus
+import org.briarproject.bramble.api.lifecycle.LifecycleManager
+import org.briarproject.bramble.api.mailbox.MailboxManager
+import org.briarproject.bramble.api.mailbox.MailboxPairingState
+import org.briarproject.bramble.api.mailbox.MailboxPairingState.Paired
+import org.briarproject.bramble.api.mailbox.MailboxPairingTask
+import org.briarproject.bramble.api.mailbox.MailboxStatus
+import org.briarproject.bramble.api.mailbox.event.OwnMailboxConnectionStatusEvent
+import org.briarproject.bramble.api.plugin.Plugin
+import org.briarproject.bramble.api.plugin.PluginManager
+import org.briarproject.bramble.api.plugin.TorConstants
+import org.briarproject.bramble.api.plugin.event.TransportInactiveEvent
+import org.briarproject.briar.desktop.mailbox.MailboxPairingUiState.IsPaired
+import org.briarproject.briar.desktop.mailbox.MailboxPairingUiState.NotSetup
+import org.briarproject.briar.desktop.mailbox.MailboxPairingUiState.OfflineWhenPairing
+import org.briarproject.briar.desktop.mailbox.MailboxPairingUiState.Pairing
+import org.briarproject.briar.desktop.mailbox.MailboxPairingUiState.Unknown
+import org.briarproject.briar.desktop.threading.BriarExecutors
+import org.briarproject.briar.desktop.threading.UiExecutor
+import org.briarproject.briar.desktop.viewmodel.EventListenerDbViewModel
+import org.briarproject.briar.desktop.viewmodel.asState
+import javax.inject.Inject
+
+sealed class MailboxPairingUiState {
+    object Unknown : MailboxPairingUiState()
+    object NotSetup : MailboxPairingUiState()
+    class Pairing(val pairingState: MailboxPairingState) : MailboxPairingUiState()
+    object OfflineWhenPairing : MailboxPairingUiState()
+    class IsPaired(val isOnline: Boolean) : MailboxPairingUiState()
+    class WasUnpaired(val tellUserToWipeMailbox: Boolean) : MailboxPairingUiState()
+}
+
+class MailboxViewModel @Inject constructor(
+    lifecycleManager: LifecycleManager,
+    db: TransactionManager,
+    eventBus: EventBus,
+    private val mailboxManager: MailboxManager,
+    private val pluginManager: PluginManager,
+    private val briarExecutors: BriarExecutors,
+) : EventListenerDbViewModel(briarExecutors, lifecycleManager, db, eventBus),
+    Consumer<MailboxPairingState> {
+
+    companion object {
+        private val LOG = KotlinLogging.logger {}
+    }
+
+    private val _pairingState = mutableStateOf<MailboxPairingUiState>(Unknown)
+    val pairingState = _pairingState.asState()
+    private val _pairingLink = mutableStateOf("")
+    val pairingLink = _pairingLink.asState()
+    private val _status = mutableStateOf<MailboxStatus?>(null)
+    val status = _status.asState()
+
+    @UiExecutor
+    private var pairingTask: MailboxPairingTask? = null
+
+    init {
+        checkIfSetup()
+    }
+
+    @UiExecutor
+    private fun checkIfSetup() {
+        val task = mailboxManager.currentPairingTask
+        if (task == null) briarExecutors.onDbThreadWithTransaction(true) { txn ->
+            val isPaired = mailboxManager.isPaired(txn)
+            if (isPaired) {
+                val mailboxStatus = mailboxManager.getMailboxStatus(txn)
+                val isOnline = isTorActive()
+                briarExecutors.onUiThread {
+                    _pairingState.value = IsPaired(isOnline)
+                    _status.value = mailboxStatus
+                }
+            } else briarExecutors.onUiThread {
+                _pairingState.value = NotSetup
+            }
+        } else {
+            task.addObserver(this)
+            pairingTask = task
+        }
+    }
+
+    override fun eventOccurred(e: Event) {
+        if (e is OwnMailboxConnectionStatusEvent) {
+            _status.value = e.status
+        } else if (e is TransportInactiveEvent) {
+            if (TorConstants.ID != e.transportId) return
+            onTorInactive()
+        }
+    }
+
+    @UiExecutor
+    private fun onTorInactive() {
+        val lastState = _pairingState.value
+        if (lastState is IsPaired) {
+            // we are already paired, so use IsPaired state
+            _pairingState.value = IsPaired(false)
+        } else if (lastState is Pairing) {
+            // check that we not just finished pairing (showing success screen)
+            if (lastState.pairingState !is Paired) _pairingState.value = OfflineWhenPairing
+            // else ignore offline event as user will be leaving UI flow anyway
+        }
+    }
+
+    @UiExecutor
+    override fun accept(t: MailboxPairingState) {
+        @Suppress("HardCodedStringLiteral")
+        LOG.info { "New pairing state: ${t::class.simpleName}" }
+        _pairingState.value = Pairing(t)
+    }
+
+    private fun isTorActive(): Boolean {
+        val plugin = pluginManager.getPlugin(TorConstants.ID) ?: return false
+        return plugin.state == Plugin.State.ACTIVE
+    }
+
+    @UiExecutor
+    fun onPairingLinkChanged(link: String) {
+        _pairingLink.value = link
+    }
+
+    @UiExecutor
+    fun pairMailbox(base32Link: String) {
+        val payload = mailboxManager.convertBase32Payload(base32Link)
+        if (isTorActive()) {
+            pairingTask = mailboxManager.startPairingTask(payload).also {
+                it.addObserver(this)
+            }
+        } else {
+            _pairingState.value = OfflineWhenPairing
+        }
+    }
+
+    @UiExecutor
+    fun onPairingErrorSeen() {
+        _pairingState.value = NotSetup
+    }
+}
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/navigation/BriarSidebar.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/navigation/BriarSidebar.kt
index db5a6c5a1a..e91e1b2984 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/navigation/BriarSidebar.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/navigation/BriarSidebar.kt
@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.selection.selectableGroup
 import androidx.compose.material.Badge
@@ -65,7 +64,7 @@ fun BriarSidebar(
     @Composable
     fun BriarSidebarButton(
         mode: UiMode,
-        icon: ImageVector,
+        icon: ImageVector = mode.icon,
         messageCount: Int = 0,
     ) = BriarSidebarButton(
         uiMode == mode,
@@ -104,6 +103,7 @@ fun BriarSidebar(
             UiMode.TRANSPORTS,
             Icons.Filled.WifiTethering
         )
+        if (configuration.shouldEnableMailbox()) BriarSidebarButton(UiMode.MAILBOX)
         BriarSidebarButton(UiMode.SETTINGS, Icons.Filled.Settings)
         BriarSidebarButton(UiMode.ABOUT, Icons.Filled.Info)
     }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt
index d80e2e1275..9923b081a8 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt
@@ -29,6 +29,7 @@ import androidx.compose.runtime.setValue
 import androidx.compose.runtime.staticCompositionLocalOf
 import org.briarproject.briar.desktop.conversation.PrivateMessageScreen
 import org.briarproject.briar.desktop.forums.ForumScreen
+import org.briarproject.briar.desktop.mailbox.MailboxScreen
 import org.briarproject.briar.desktop.navigation.BriarSidebar
 import org.briarproject.briar.desktop.navigation.SidebarViewModel
 import org.briarproject.briar.desktop.privategroups.PrivateGroupScreen
@@ -62,6 +63,7 @@ fun MainScreen(viewModel: SidebarViewModel = viewModel()) {
                     UiMode.CONTACTS -> PrivateMessageScreen()
                     UiMode.GROUPS -> PrivateGroupScreen()
                     UiMode.FORUMS -> ForumScreen()
+                    UiMode.MAILBOX -> MailboxScreen()
                     UiMode.SETTINGS -> SettingsScreen()
                     UiMode.ABOUT -> AboutScreen()
                     else -> UiPlaceholder()
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/UiMode.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/UiMode.kt
index 10a464edbe..1159d5f8c7 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/UiMode.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/UiMode.kt
@@ -24,6 +24,7 @@ import androidx.compose.material.icons.filled.Contacts
 import androidx.compose.material.icons.filled.Forum
 import androidx.compose.material.icons.filled.Group
 import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Mail
 import androidx.compose.material.icons.filled.Settings
 import androidx.compose.material.icons.filled.WifiTethering
 import androidx.compose.ui.graphics.vector.ImageVector
@@ -34,6 +35,7 @@ enum class UiMode(val icon: ImageVector, val contentDescriptionKey: String) {
     FORUMS(Icons.Filled.Forum, "access.mode.forums"),
     BLOGS(Icons.Filled.ChromeReaderMode, "access.mode.blogs"),
     TRANSPORTS(Icons.Filled.WifiTethering, "access.mode.transports"),
+    MAILBOX(Icons.Filled.Mail, "access.mode.mailbox"), // TODO add official icon
     SETTINGS(Icons.Filled.Settings, "access.mode.settings"),
     ABOUT(Icons.Filled.Info, "access.mode.about"),
 }
diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt
index 182c8cda27..595cab6a47 100644
--- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt
+++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt
@@ -29,6 +29,7 @@ import org.briarproject.briar.desktop.forums.ForumViewModel
 import org.briarproject.briar.desktop.forums.sharing.ForumSharingViewModel
 import org.briarproject.briar.desktop.introduction.IntroductionViewModel
 import org.briarproject.briar.desktop.login.StartupViewModel
+import org.briarproject.briar.desktop.mailbox.MailboxViewModel
 import org.briarproject.briar.desktop.navigation.SidebarViewModel
 import org.briarproject.briar.desktop.privategroups.PrivateGroupListViewModel
 import org.briarproject.briar.desktop.privategroups.ThreadedConversationViewModel
@@ -90,6 +91,11 @@ abstract class ViewModelModule {
     @ViewModelKey(ForumSharingViewModel::class)
     abstract fun bindForumSharingViewModel(forumSharingViewModel: ForumSharingViewModel): ViewModel
 
+    @Binds
+    @IntoMap
+    @ViewModelKey(MailboxViewModel::class)
+    abstract fun bindMailboxViewModel(mailboxViewModel: MailboxViewModel): ViewModel
+
     @Binds
     @IntoMap
     @ViewModelKey(SettingsViewModel::class)
diff --git a/briar-desktop/src/main/resources/strings/BriarDesktop.properties b/briar-desktop/src/main/resources/strings/BriarDesktop.properties
index 7ddca79d2d..954ad23596 100644
--- a/briar-desktop/src/main/resources/strings/BriarDesktop.properties
+++ b/briar-desktop/src/main/resources/strings/BriarDesktop.properties
@@ -46,6 +46,7 @@ access.mode.groups=Private Groups
 access.mode.forums=Forums
 access.mode.blogs=Blogs
 access.mode.transports=Transport Settings
+access.mode.mailbox=Mailbox
 access.mode.settings=Settings
 access.mode.about=About Briar
 access.about.list=Information about your version of Briar, the Briar Project in general and how to get in touch
@@ -245,6 +246,22 @@ about.version.core=Briar Core Version
 about.contact=Contact
 about.website=Website
 
+# Mailbox
+mailbox.setup.intro=A Mailbox enables your contacts to send you messages while you are offline. The Mailbox will receive your messages and store them until you come online.\n\nYou can install the Briar Mailbox app on a spare device. Keep it connected to power and Wi-Fi so it's always online.
+mailbox.setup.download=First, install the Mailbox app on another device by searching for "Briar Mailbox" on Google Play or wherever you downloaded Briar.
+mailbox.setup.link=Then link your Mailbox with Briar by pasting the text from the mailbox app below.\n\nYou can find the text by tapping the little dots in the top right corner of the QR code screen and select "Show as text".
+mailbox.setup.hint=briar-mailbox:// text
+mailbox.setup.button=Link Mailbox
+mailbox.setup.link.error=Invalid mailbox text
+mailbox.setup.error.title=Mailbox Error
+mailbox.setup.already_paired_title=Mailbox already linked
+mailbox.setup.already_paired_description=Unlink the Mailbox on your other device and try again.
+mailbox.setup.io_error_title=Could not connect
+mailbox.setup.io_error_description=Ensure that both devices are connected to the Internet and try again.
+mailbox.setup.assertion_error_description=Please create a bug report if the issue persists.
+mailbox.setup.offline_error_title=Offline
+mailbox.setup.offline_error_description=Ensure that you are online and try again after a while.
+
 # Miscellaneous
 cancel=Cancel
 delete=Delete
-- 
GitLab