Change drawer sync calls

Resolves #8146

Add a syn all accounts button to the side rail.

Depends on #8165

Improve drawer sync mechanism

Improve drawer sync mechanism
During review of #8143 it was stated that the `SyncMail` use case could be used wrongly and needs improvement.

As the `SyncMail` implementation is basically a copy paste from the existing code, it does not distinguish between syncing a single account vs all accounts and at what point in time either of them is appropriate to use.

It would be good to improve the api to minimize misunderstandings in the future. Take note that the underlying `MessagingController.checkMail()` could benefit from such an improvement too.

See [comment](https://github.com/thunderbird/thunderbird-android/pull/8143/files/29bada814b06f528cbc9c4964af8b2503ca9a5af#r1761876643) for more details.

./gradlew :feature:navigation:drawer:testDebugUnitTest --tests "app.k9mail.feature.navigation.drawer.domain.usecase.SyncAccountTest" --tests "app.k9mail.feature.navigation.drawer.domain.usecase.SyncMailTest" --tests "app.k9mail.feature.navigation.drawer.ui.DrawerViewModelTest" --tests "app.k9mail.feature.navigation.drawer.domain.usecase.SyncAllAccountsTest"

diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/Icons.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/Icons.kt
index 5e4a5861902..7be1b9d56c2 100644
--- a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/Icons.kt
+++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/Icons.kt
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.outlined.Menu
 import androidx.compose.material.icons.outlined.Report
 import androidx.compose.material.icons.outlined.Security
 import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.material.icons.outlined.Sync
 import androidx.compose.material.icons.outlined.Visibility
 import androidx.compose.ui.graphics.vector.ImageVector
 import app.k9mail.core.ui.compose.designsystem.atom.icon.filled.Dot
@@ -111,6 +112,9 @@ object Icons {
         val Settings: ImageVector
             get() = MaterialIcons.Outlined.Settings

+        val Sync: ImageVector
+            get() = MaterialIcons.Outlined.Sync
+
         val Report: ImageVector
             get() = MaterialIcons.Outlined.Report

diff --git a/feature/navigation/drawer/src/debug/kotlin/app/k9mail/feature/navigation/drawer/ui/account/AccountListPreview.kt b/feature/navigation/drawer/src/debug/kotlin/app/k9mail/feature/navigation/drawer/ui/account/AccountListPreview.kt
index 81c688f2dbf..e8f083e7139 100644
--- a/feature/navigation/drawer/src/debug/kotlin/app/k9mail/feature/navigation/drawer/ui/account/AccountListPreview.kt
+++ b/feature/navigation/drawer/src/debug/kotlin/app/k9mail/feature/navigation/drawer/ui/account/AccountListPreview.kt
@@ -17,6 +17,7 @@ internal fun AccountListPreview() {
             selectedAccount = null,
             onAccountClick = { },
             onSettingsClick = { },
+            onSyncAllAccountsClick = { },
         )
     }
 }
@@ -32,6 +33,7 @@ internal fun AccountListWithSelectedPreview() {
             selectedAccount = DISPLAY_ACCOUNT,
             onAccountClick = { },
             onSettingsClick = { },
+            onSyncAllAccountsClick = { },
         )
     }
 }
diff --git a/feature/navigation/drawer/src/debug/kotlin/app/k9mail/feature/navigation/drawer/ui/setting/SettingItemPreview.kt b/feature/navigation/drawer/src/debug/kotlin/app/k9mail/feature/navigation/drawer/ui/setting/SettingItemPreview.kt
index cfd63708628..70bbd16d92f 100644
--- a/feature/navigation/drawer/src/debug/kotlin/app/k9mail/feature/navigation/drawer/ui/setting/SettingItemPreview.kt
+++ b/feature/navigation/drawer/src/debug/kotlin/app/k9mail/feature/navigation/drawer/ui/setting/SettingItemPreview.kt
@@ -3,12 +3,14 @@ package app.k9mail.feature.navigation.drawer.ui.setting
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.tooling.preview.Preview
 import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes
+import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons

 @Composable
 @Preview(showBackground = true)
 internal fun SettingItemPreview() {
     PreviewWithThemes {
         SettingItem(
+            icon = Icons.Outlined.Settings,
             label = "Setting",
             onClick = {},
         )
diff --git a/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/NavigationDrawerModule.kt b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/NavigationDrawerModule.kt
index a69724638f3..35e8fd2bf0a 100644
--- a/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/NavigationDrawerModule.kt
+++ b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/NavigationDrawerModule.kt
@@ -4,7 +4,8 @@ import app.k9mail.feature.navigation.drawer.domain.DomainContract.UseCase
 import app.k9mail.feature.navigation.drawer.domain.usecase.GetDisplayAccounts
 import app.k9mail.feature.navigation.drawer.domain.usecase.GetDisplayFoldersForAccount
 import app.k9mail.feature.navigation.drawer.domain.usecase.GetDrawerConfig
-import app.k9mail.feature.navigation.drawer.domain.usecase.SyncMail
+import app.k9mail.feature.navigation.drawer.domain.usecase.SyncAccount
+import app.k9mail.feature.navigation.drawer.domain.usecase.SyncAllAccounts
 import app.k9mail.feature.navigation.drawer.legacy.AccountsViewModel
 import app.k9mail.feature.navigation.drawer.legacy.FoldersViewModel
 import app.k9mail.feature.navigation.drawer.ui.DrawerViewModel
@@ -37,8 +38,14 @@ val navigationDrawerModule: Module = module {
         )
     }

-    single<UseCase.SyncMail> {
-        SyncMail(
+    single<UseCase.SyncAccount> {
+        SyncAccount(
+            messagingController = get(),
+        )
+    }
+
+    single<UseCase.SyncAllAccounts> {
+        SyncAllAccounts(
             messagingController = get(),
         )
     }
@@ -66,7 +73,8 @@ val navigationDrawerModule: Module = module {
             getDrawerConfig = get(),
             getDisplayAccounts = get(),
             getDisplayFoldersForAccount = get(),
-            syncMail = get(),
+            syncAccount = get(),
+            syncAllAccounts = get(),
         )
     }
 }
diff --git a/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/domain/DomainContract.kt b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/domain/DomainContract.kt
index 6bdc3542657..f8ab0118074 100644
--- a/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/domain/DomainContract.kt
+++ b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/domain/DomainContract.kt
@@ -22,12 +22,17 @@ internal interface DomainContract {
         }

         /**
-         * Synchronize mail for the given account.
-         *
-         * Account can be null to synchronize unified inbox or account list.
+         * Synchronize the given account.
          */
-        fun interface SyncMail {
-            operator fun invoke(account: Account?): Flow<Result<Unit>>
+        fun interface SyncAccount {
+            operator fun invoke(account: Account): Flow<Result<Unit>>
+        }
+
+        /**
+         * Synchronize all accounts.
+         */
+        fun interface SyncAllAccounts {
+            operator fun invoke(): Flow<Result<Unit>>
         }
     }
 }
diff --git a/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/SyncMail.kt b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/SyncAccount.kt
similarity index 90%
rename from feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/SyncMail.kt
rename to feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/SyncAccount.kt
index a8906e4a18d..90609176660 100644
--- a/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/SyncMail.kt
+++ b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/SyncAccount.kt
@@ -12,11 +12,11 @@ import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.callbackFlow
 import kotlinx.coroutines.flow.flowOn

-internal class SyncMail(
+internal class SyncAccount(
     private val messagingController: MessagingControllerMailChecker,
     private val coroutineContext: CoroutineContext = Dispatchers.IO,
-) : UseCase.SyncMail {
-    override fun invoke(account: Account?): Flow<Result<Unit>> = callbackFlow {
+) : UseCase.SyncAccount {
+    override fun invoke(account: Account): Flow<Result<Unit>> = callbackFlow {
         val listener = object : SimpleMessagingListener() {
             override fun checkMailFinished(context: Context?, account: Account?) {
                 trySend(Result.success(Unit))
diff --git a/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/SyncAllAccounts.kt b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/SyncAllAccounts.kt
new file mode 100644
index 00000000000..b89996cf3c7
--- /dev/null
+++ b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/SyncAllAccounts.kt
@@ -0,0 +1,37 @@
+package app.k9mail.feature.navigation.drawer.domain.usecase
+
+import android.content.Context
+import app.k9mail.feature.navigation.drawer.domain.DomainContract.UseCase
+import app.k9mail.legacy.account.Account
+import app.k9mail.legacy.message.controller.MessagingControllerMailChecker
+import app.k9mail.legacy.message.controller.SimpleMessagingListener
+import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.flowOn
+
+class SyncAllAccounts(
+    private val messagingController: MessagingControllerMailChecker,
+    private val coroutineContext: CoroutineContext = Dispatchers.IO,
+) : UseCase.SyncAllAccounts {
+    override fun invoke(): Flow<Result<Unit>> = callbackFlow {
+        val listener = object : SimpleMessagingListener() {
+            override fun checkMailFinished(context: Context?, account: Account?) {
+                trySend(Result.success(Unit))
+                close()
+            }
+        }
+
+        messagingController.checkMail(
+            account = null,
+            ignoreLastCheckedTime = true,
+            useManualWakeLock = true,
+            notify = true,
+            listener = listener,
+        )
+
+        awaitClose()
+    }.flowOn(coroutineContext)
+}
diff --git a/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerContent.kt b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerContent.kt
index e1fd3ef419b..809214bb98a 100644
--- a/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerContent.kt
+++ b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerContent.kt
@@ -45,6 +45,7 @@ internal fun DrawerContent(
                         accounts = state.accounts,
                         selectedAccount = state.selectedAccount,
                         onAccountClick = { onEvent(Event.OnAccountClick(it)) },
+                        onSyncAllAccountsClick = { onEvent(Event.OnSyncAllAccounts) },
                         onSettingsClick = { onEvent(Event.OnSettingsClick) },
                     )
                 }
diff --git a/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerContract.kt b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerContract.kt
index 435500dc758..657e4d1d9c0 100644
--- a/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerContract.kt
+++ b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerContract.kt
@@ -34,7 +34,8 @@ internal interface DrawerContract {
         data object OnAccountSelectorClick : Event
         data object OnManageFoldersClick : Event
         data object OnSettingsClick : Event
-        data object OnRefresh : Event
+        data object OnSyncAccount : Event
+        data object OnSyncAllAccounts : Event
     }

     sealed interface Effect {
diff --git a/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerView.kt b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerView.kt
index ff52de5d257..33f493b0ffb 100644
--- a/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerView.kt
+++ b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerView.kt
@@ -32,7 +32,7 @@ internal fun DrawerView(

     PullToRefreshBox(
         isRefreshing = state.value.isLoading,
-        onRefresh = { dispatch(Event.OnRefresh) },
+        onRefresh = { dispatch(Event.OnSyncAccount) },
     ) {
         DrawerContent(
             state = state.value,
diff --git a/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerViewModel.kt b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerViewModel.kt
index b48b4ee6953..8e659d7ec59 100644
--- a/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerViewModel.kt
+++ b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerViewModel.kt
@@ -28,7 +28,8 @@ internal class DrawerViewModel(
     private val getDrawerConfig: UseCase.GetDrawerConfig,
     private val getDisplayAccounts: UseCase.GetDisplayAccounts,
     private val getDisplayFoldersForAccount: UseCase.GetDisplayFoldersForAccount,
-    private val syncMail: UseCase.SyncMail,
+    private val syncAccount: UseCase.SyncAccount,
+    private val syncAllAccounts: UseCase.SyncAllAccounts,
     initialState: State = State(),
 ) : BaseViewModel<State, Event, Effect>(
     initialState = initialState,
@@ -101,7 +102,6 @@ internal class DrawerViewModel(

     override fun event(event: Event) {
         when (event) {
-            Event.OnRefresh -> refresh()
             is Event.OnAccountClick -> selectAccount(event.account)
             is Event.OnFolderClick -> selectFolder(event.folder)
             is Event.OnAccountViewClick -> {
@@ -113,6 +113,8 @@ internal class DrawerViewModel(
             Event.OnAccountSelectorClick -> updateState { it.copy(showAccountSelector = it.showAccountSelector.not()) }
             Event.OnManageFoldersClick -> emitEffect(Effect.OpenManageFolders)
             Event.OnSettingsClick -> emitEffect(Effect.OpenSettings)
+            Event.OnSyncAccount -> onSyncAccount()
+            Event.OnSyncAllAccounts -> onSyncAllAccounts()
         }
     }

@@ -156,13 +158,31 @@ internal class DrawerViewModel(
         }
     }

-    private fun refresh() {
+    private fun onSyncAccount() {
+        if (state.value.isLoading || state.value.selectedAccount == null) return
+
+        viewModelScope.launch {
+            updateState {
+                it.copy(isLoading = true)
+            }
+
+            state.value.selectedAccount?.account?.let { syncAccount(it).collect() }
+
+            updateState {
+                it.copy(isLoading = false)
+            }
+        }
+    }
+
+    private fun onSyncAllAccounts() {
+        if (state.value.isLoading) return
+
         viewModelScope.launch {
             updateState {
                 it.copy(isLoading = true)
             }

-            syncMail(state.value.selectedAccount?.account).collect()
+            syncAllAccounts().collect()

             updateState {
                 it.copy(isLoading = false)
diff --git a/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/account/AccountList.kt b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/account/AccountList.kt
index bab2b57bc73..3e857bd66e6 100644
--- a/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/account/AccountList.kt
+++ b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/account/AccountList.kt
@@ -2,14 +2,19 @@ package app.k9mail.feature.navigation.drawer.ui.account

 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
 import app.k9mail.core.ui.compose.designsystem.atom.Surface
+import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons
 import app.k9mail.core.ui.compose.theme2.MainTheme
 import app.k9mail.feature.navigation.drawer.R
 import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccount
@@ -21,6 +26,7 @@ internal fun AccountList(
     accounts: ImmutableList<DisplayAccount>,
     selectedAccount: DisplayAccount?,
     onAccountClick: (DisplayAccount) -> Unit,
+    onSyncAllAccountsClick: () -> Unit,
     onSettingsClick: () -> Unit,
     modifier: Modifier = Modifier,
 ) {
@@ -50,10 +56,22 @@ internal fun AccountList(
                     )
                 }
             }
-            SettingItem(
-                label = stringResource(id = R.string.navigation_drawer_action_settings),
-                onClick = onSettingsClick,
-            )
+            Column(
+                modifier = Modifier.padding(vertical = MainTheme.spacings.oneHalf),
+            ) {
+                SettingItem(
+                    icon = Icons.Outlined.Sync,
+                    label = stringResource(id = R.string.navigation_drawer_action_sync_all_accounts),
+                    onClick = onSyncAllAccountsClick,
+                )
+                // Hack to compensate the column placement at an uneven coordinate, caused by the 1.dp divider.
+                Spacer(modifier = Modifier.height(7.dp))
+                SettingItem(
+                    icon = Icons.Outlined.Settings,
+                    label = stringResource(id = R.string.navigation_drawer_action_settings),
+                    onClick = onSettingsClick,
+                )
+            }
         }
     }
 }
diff --git a/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/setting/SettingItem.kt b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/setting/SettingItem.kt
index 88f84a1a616..f2f2f1e8c0b 100644
--- a/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/setting/SettingItem.kt
+++ b/feature/navigation/drawer/src/main/kotlin/app/k9mail/feature/navigation/drawer/ui/setting/SettingItem.kt
@@ -8,13 +8,14 @@ import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
 import app.k9mail.core.ui.compose.designsystem.atom.Surface
 import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon
-import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons
 import app.k9mail.core.ui.compose.theme2.MainTheme

 @Composable
 internal fun SettingItem(
+    icon: ImageVector,
     label: String,
     onClick: () -> Unit,
     modifier: Modifier = Modifier,
@@ -24,12 +25,11 @@ internal fun SettingItem(
         contentAlignment = Alignment.Center,
     ) {
         Surface(
-            modifier = Modifier.padding(vertical = MainTheme.spacings.oneHalf),
             color = MainTheme.colors.surfaceContainer,
             shape = CircleShape,
         ) {
             Icon(
-                imageVector = Icons.Outlined.Settings,
+                imageVector = icon,
                 contentDescription = label,
                 modifier = Modifier
                     .clickable(onClick = onClick)
diff --git a/feature/navigation/drawer/src/main/res/values/strings.xml b/feature/navigation/drawer/src/main/res/values/strings.xml
index 8fd48000956..3324776b02a 100644
--- a/feature/navigation/drawer/src/main/res/values/strings.xml
+++ b/feature/navigation/drawer/src/main/res/values/strings.xml
@@ -2,6 +2,7 @@
 <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
     <string name="navigation_drawer_action_settings">Settings</string>
     <string name="navigation_drawer_action_manage_folders">Manage folders</string>
+    <string name="navigation_drawer_action_sync_all_accounts">Sync all accounts</string>
     <string name="navigation_drawer_action_show_accounts">Show accounts</string>
     <string name="navigation_drawer_action_hide_accounts">Hide accounts</string>
     <string name="navigation_drawer_unified_inbox_title">Unified Inbox</string>

diff --git a/feature/navigation/drawer/src/test/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/FakeMessagingControllerMailChecker.kt b/feature/navigation/drawer/src/test/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/FakeMessagingControllerMailChecker.kt
new file mode 100644
index 00000000000..487ff72ff25
--- /dev/null
+++ b/feature/navigation/drawer/src/test/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/FakeMessagingControllerMailChecker.kt
@@ -0,0 +1,19 @@
+package app.k9mail.feature.navigation.drawer.domain.usecase
+
+import app.k9mail.legacy.account.Account
+import app.k9mail.legacy.message.controller.MessagingControllerMailChecker
+import app.k9mail.legacy.message.controller.MessagingListener
+
+internal class FakeMessagingControllerMailChecker(
+    private val listenerExecutor: (MessagingListener?) -> Unit = {},
+) : MessagingControllerMailChecker {
+    override fun checkMail(
+        account: Account?,
+        ignoreLastCheckedTime: Boolean,
+        useManualWakeLock: Boolean,
+        notify: Boolean,
+        listener: MessagingListener?,
+    ) {
+        listenerExecutor(listener)
+    }
+}
diff --git a/feature/navigation/drawer/src/test/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/SyncAccountTest.kt b/feature/navigation/drawer/src/test/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/SyncAccountTest.kt
new file mode 100644
index 00000000000..526d09c66be
--- /dev/null
+++ b/feature/navigation/drawer/src/test/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/SyncAccountTest.kt
@@ -0,0 +1,27 @@
+package app.k9mail.feature.navigation.drawer.domain.usecase
+
+import app.k9mail.feature.navigation.drawer.ui.FakeData
+import app.k9mail.legacy.message.controller.MessagingListener
+import assertk.assertions.isEqualTo
+import kotlin.test.Test
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+
+class SyncAccountTest {
+
+    @Test
+    fun `should sync mail with account`() = runTest {
+        val listenerExecutor: (MessagingListener?) -> Unit = { listener ->
+            listener?.checkMailFinished(null, null)
+        }
+        val testSubject = SyncAccount(
+            messagingController = FakeMessagingControllerMailChecker(
+                listenerExecutor = listenerExecutor,
+            ),
+        )
+
+        val result = testSubject(FakeData.ACCOUNT).first()
+
+        assertk.assertThat(result.isSuccess).isEqualTo(true)
+    }
+}
diff --git a/feature/navigation/drawer/src/test/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/SyncAllAccountsTest.kt b/feature/navigation/drawer/src/test/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/SyncAllAccountsTest.kt
new file mode 100644
index 00000000000..26849c0cfde
--- /dev/null
+++ b/feature/navigation/drawer/src/test/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/SyncAllAccountsTest.kt
@@ -0,0 +1,26 @@
+package app.k9mail.feature.navigation.drawer.domain.usecase
+
+import app.k9mail.legacy.message.controller.MessagingListener
+import assertk.assertions.isEqualTo
+import kotlin.test.Test
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+
+class SyncAllAccountsTest {
+
+    @Test
+    fun `should sync mail`() = runTest {
+        val listenerExecutor: (MessagingListener?) -> Unit = { listener ->
+            listener?.checkMailFinished(null, null)
+        }
+        val testSubject = SyncAllAccounts(
+            messagingController = FakeMessagingControllerMailChecker(
+                listenerExecutor = listenerExecutor,
+            ),
+        )
+
+        val result = testSubject().first()
+
+        assertk.assertThat(result.isSuccess).isEqualTo(true)
+    }
+}
diff --git a/feature/navigation/drawer/src/test/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/SyncMailTest.kt b/feature/navigation/drawer/src/test/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/SyncMailTest.kt
deleted file mode 100644
index 71acfcdbe4a..00000000000
--- a/feature/navigation/drawer/src/test/kotlin/app/k9mail/feature/navigation/drawer/domain/usecase/SyncMailTest.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-package app.k9mail.feature.navigation.drawer.domain.usecase
-
-import app.k9mail.feature.navigation.drawer.ui.FakeData
-import app.k9mail.legacy.account.Account
-import app.k9mail.legacy.message.controller.MessagingControllerMailChecker
-import app.k9mail.legacy.message.controller.MessagingListener
-import assertk.assertThat
-import assertk.assertions.isEqualTo
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.test.runTest
-import org.junit.Test
-
-class SyncMailTest {
-
-    @Test
-    fun `should sync mail`() = runTest {
-        val listenerExecutor: (MessagingListener?) -> Unit = { listener ->
-            listener?.checkMailFinished(null, null)
-        }
-        val testSubject = SyncMail(
-            messagingController = FakeMessagingControllerMailChecker(
-                listenerExecutor = listenerExecutor,
-            ),
-        )
-
-        val result = testSubject(null).first()
-
-        assertThat(result.isSuccess).isEqualTo(true)
-    }
-
-    @Test
-    fun `should sync mail with account`() = runTest {
-        val listenerExecutor: (MessagingListener?) -> Unit = { listener ->
-            listener?.checkMailFinished(null, null)
-        }
-        val testSubject = SyncMail(
-            messagingController = FakeMessagingControllerMailChecker(
-                listenerExecutor = listenerExecutor,
-            ),
-        )
-
-        val result = testSubject(FakeData.ACCOUNT).first()
-
-        assertThat(result.isSuccess).isEqualTo(true)
-    }
-
-    private class FakeMessagingControllerMailChecker(
-        private val listenerExecutor: (MessagingListener?) -> Unit = {},
-    ) : MessagingControllerMailChecker {
-        override fun checkMail(
-            account: Account?,
-            ignoreLastCheckedTime: Boolean,
-            useManualWakeLock: Boolean,
-            notify: Boolean,
-            listener: MessagingListener?,
-        ) {
-            listenerExecutor(listener)
-        }
-    }
-}
diff --git a/feature/navigation/drawer/src/test/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerViewModelTest.kt b/feature/navigation/drawer/src/test/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerViewModelTest.kt
index 7fa7e455274..5a8b2290cde 100644
--- a/feature/navigation/drawer/src/test/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerViewModelTest.kt
+++ b/feature/navigation/drawer/src/test/kotlin/app/k9mail/feature/navigation/drawer/ui/DrawerViewModelTest.kt
@@ -16,6 +16,7 @@ import app.k9mail.feature.navigation.drawer.domain.entity.DisplayUnifiedFolderTy
 import app.k9mail.feature.navigation.drawer.ui.DrawerContract.Effect
 import app.k9mail.feature.navigation.drawer.ui.DrawerContract.Event
 import app.k9mail.feature.navigation.drawer.ui.DrawerContract.State
+import app.k9mail.feature.navigation.drawer.ui.FakeData.DISPLAY_ACCOUNT
 import app.k9mail.legacy.account.Account
 import app.k9mail.legacy.account.Identity
 import assertk.assertThat
@@ -60,9 +61,87 @@ class DrawerViewModelTest {
     }

     @Test
-    fun `should change loading state when OnRefresh event is received`() = runTest {
+    fun `should change loading state when OnSyncAccount event is received`() = runTest {
+        val initialState = State(
+            accounts = listOf(DISPLAY_ACCOUNT).toImmutableList(),
+            selectedAccount = DISPLAY_ACCOUNT,
+        )
         val testSubject = createTestSubject(
-            syncMailFlow = flow {
+            initialState = initialState,
+            displayAccountsFlow = flow { emit(listOf(DISPLAY_ACCOUNT)) },
+            syncAccountFlow = flow {
+                delay(25)
+                emit(Result.success(Unit))
+            },
+        )
+
+        eventStateTest(
+            viewModel = testSubject,
+            initialState = initialState,
+            event = Event.OnSyncAccount,
+            expectedState = initialState.copy(isLoading = true),
+            coroutineScope = backgroundScope,
+        )
+
+        advanceUntilIdle()
+
+        assertThat(testSubject.state.value.isLoading).isEqualTo(false)
+    }
+
+    @Test
+    fun `should skip loading when no account is selected and OnSyncAccount event is received`() = runTest {
+        val initialState = State(selectedAccount = null)
+        var counter = 0
+        val testSubject = createTestSubject(
+            initialState = initialState,
+            syncAccountFlow = flow {
+                delay(25)
+                emit(Result.success(Unit))
+            },
+        )
+
+        val turbines = turbinesWithInitialStateCheck(testSubject, initialState)
+
+        testSubject.event(Event.OnSyncAccount)
+
+        advanceUntilIdle()
+
+        assertThat(testSubject.state.value.isLoading).isEqualTo(false)
+        assertThat(counter).isEqualTo(0)
+
+        turbines.stateTurbine.ensureAllEventsConsumed()
+        turbines.effectTurbine.ensureAllEventsConsumed()
+    }
+
+    @Test
+    fun `should skip loading when already loading and OnSyncAccount event received`() = runTest {
+        val initialState = State(isLoading = true)
+        var counter = 0
+        val testSubject = createTestSubject(
+            initialState = initialState,
+            syncAccountFlow = flow {
+                counter++
+                delay(25)
+                emit(Result.success(Unit))
+            },
+        )
+        val turbines = turbinesWithInitialStateCheck(testSubject, initialState)
+
+        testSubject.event(Event.OnSyncAccount)
+
+        advanceUntilIdle()
+
+        assertThat(testSubject.state.value.isLoading).isEqualTo(true)
+        assertThat(counter).isEqualTo(0)
+
+        turbines.stateTurbine.ensureAllEventsConsumed()
+        turbines.effectTurbine.ensureAllEventsConsumed()
+    }
+
+    @Test
+    fun `should change loading state when OnSyncAllAccounts event is received`() = runTest {
+        val testSubject = createTestSubject(
+            syncAllAccounts = flow {
                 delay(25)
                 emit(Result.success(Unit))
             },
@@ -71,7 +150,7 @@ class DrawerViewModelTest {
         eventStateTest(
             viewModel = testSubject,
             initialState = State(isLoading = false),
-            event = Event.OnRefresh,
+            event = Event.OnSyncAllAccounts,
             expectedState = State(isLoading = true),
             coroutineScope = backgroundScope,
         )
@@ -81,6 +160,31 @@ class DrawerViewModelTest {
         assertThat(testSubject.state.value.isLoading).isEqualTo(false)
     }

+    @Test
+    fun `should skip loading when already loading and OnSyncAllAccounts event received`() = runTest {
+        val initialState = State(isLoading = true)
+        var counter = 0
+        val testSubject = createTestSubject(
+            initialState = initialState,
+            syncAccountFlow = flow {
+                counter++
+                delay(25)
+                emit(Result.success(Unit))
+            },
+        )
+        val turbines = turbinesWithInitialStateCheck(testSubject, initialState)
+
+        testSubject.event(Event.OnSyncAllAccounts)
+
+        advanceUntilIdle()
+
+        assertThat(testSubject.state.value.isLoading).isEqualTo(true)
+        assertThat(counter).isEqualTo(0)
+
+        turbines.stateTurbine.ensureAllEventsConsumed()
+        turbines.effectTurbine.ensureAllEventsConsumed()
+    }
+
     @Test
     fun `should collect display accounts when created and select first as selected`() = runTest {
         val displayAccounts = createDisplayAccountList(2)
@@ -314,18 +418,22 @@ class DrawerViewModelTest {
     }

     private fun createTestSubject(
+        initialState: State = State(),
         drawerConfigFlow: Flow<DrawerConfig> = flow { emit(createDrawerConfig()) },
         displayAccountsFlow: Flow<List<DisplayAccount>> = flow { emit(emptyList()) },
         displayFoldersFlow: Flow<Map<String, List<DisplayFolder>>> = flow { emit(emptyMap()) },
-        syncMailFlow: Flow<Result<Unit>> = flow { emit(Result.success(Unit)) },
+        syncAccountFlow: Flow<Result<Unit>> = flow { emit(Result.success(Unit)) },
+        syncAllAccounts: Flow<Result<Unit>> = flow { emit(Result.success(Unit)) },
     ): DrawerViewModel {
         return DrawerViewModel(
+            initialState = initialState,
             getDrawerConfig = { drawerConfigFlow },
             getDisplayAccounts = { displayAccountsFlow },
             getDisplayFoldersForAccount = { accountUuid, _ ->
                 displayFoldersFlow.map { it[accountUuid] ?: emptyList() }
             },
-            syncMail = { syncMailFlow },
+            syncAccount = { syncAccountFlow },
+            syncAllAccounts = { syncAllAccounts },
         )
     }


Add support for importing settings from another app

![image](https://github.com/user-attachments/assets/fd8651a3-be20-4909-ab77-8c1422185857)

Requires an app with #8099 merged to test.
Note: `SettingsProvider` checks application IDs and app signatures. So this needs to be tested with properly signed release builds (or the signature check needs to be modified/disabled).

Closes #8101

Extend settings screen to allow importing from other apps
We want to allow people to import settings/accounts when switching from one of our apps to another, e.g. from K-9 Mail to Thunderbird for Android. For this we want to extend the "Import settings" screen to allow selecting an app to import the settings from.

The other half of this feature is tracked under #8098.

./gradlew :feature:settings:import:testDebugUnitTest --tests "app.k9mail.feature.settings.import.ui.SettingsImportViewModelTest" --tests "app.k9mail.feature.settings.import.ui.ImportAppFetcherTest"

diff --git a/feature/settings/import/src/main/AndroidManifest.xml b/feature/settings/import/src/main/AndroidManifest.xml
index 1303ee5e39f..4db0faac347 100644
--- a/feature/settings/import/src/main/AndroidManifest.xml
+++ b/feature/settings/import/src/main/AndroidManifest.xml
@@ -4,6 +4,14 @@
     xmlns:tools="http://schemas.android.com/tools"
     >

+    <queries>
+        <!-- Required to be able to access the SettingsProvider of another app -->
+        <package android:name="com.fsck.k9"/>
+        <package android:name="net.thunderbird.android"/>
+        <package android:name="net.thunderbird.android.beta"/>
+        <package android:name="net.thunderbird.android.daily"/>
+    </queries>
+
     <application>

         <activity
diff --git a/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/SettingsImportModule.kt b/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/SettingsImportModule.kt
index 70559e280db..c340598cc94 100644
--- a/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/SettingsImportModule.kt
+++ b/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/SettingsImportModule.kt
@@ -1,16 +1,21 @@
 package app.k9mail.feature.settings.import

 import app.k9mail.feature.settings.import.ui.AuthViewModel
+import app.k9mail.feature.settings.import.ui.ImportAppFetcher
+import app.k9mail.feature.settings.import.ui.PickAppViewModel
 import app.k9mail.feature.settings.import.ui.SettingsImportViewModel
 import org.koin.androidx.viewmodel.dsl.viewModel
 import org.koin.dsl.module

 val featureSettingsImportModule = module {
+    factory { ImportAppFetcher(context = get()) }
+
     viewModel {
         SettingsImportViewModel(
-            context = get(),
+            contentResolver = get(),
             settingsImporter = get(),
             accountActivator = get(),
+            importAppFetcher = get(),
         )
     }

@@ -21,4 +26,6 @@ val featureSettingsImportModule = module {
             getOAuthRequestIntent = get(),
         )
     }
+
+    viewModel { PickAppViewModel(importAppFetcher = get()) }
 }
diff --git a/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/ImportAppFetcher.kt b/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/ImportAppFetcher.kt
new file mode 100644
index 00000000000..7ccbb934900
--- /dev/null
+++ b/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/ImportAppFetcher.kt
@@ -0,0 +1,80 @@
+package app.k9mail.feature.settings.import.ui
+
+import android.content.Context
+import android.content.pm.PackageManager
+import androidx.annotation.WorkerThread
+
+internal class ImportAppFetcher(
+    private val context: Context,
+) {
+    private val packageManager by lazy { context.packageManager }
+
+    /**
+     * Returns `true` if at least one app is installed from which we can import settings.
+     */
+    @WorkerThread
+    fun isAtLeastOneAppInstalled(): Boolean {
+        return supportedApps.any { packageName -> packageManager.isAppInstalled(packageName) }
+    }
+
+    @Suppress("SwallowedException")
+    private fun PackageManager.isAppInstalled(packageName: String): Boolean {
+        return try {
+            getApplicationInfo(packageName, 0)
+            true
+        } catch (e: PackageManager.NameNotFoundException) {
+            false
+        }
+    }
+
+    /**
+     * Get list of apps from which we can import settings.
+     */
+    @WorkerThread
+    fun getAppInfoList(): List<AppInfo> {
+        return supportedApps
+            .mapNotNull { packageName -> packageManager.loadAppInfo(packageName) }
+            .toList()
+    }
+
+    @Suppress("SwallowedException")
+    private fun PackageManager.loadAppInfo(packageName: String): AppInfo? {
+        return try {
+            val applicationInfo = getApplicationInfo(packageName, 0)
+            val appName = packageManager.getApplicationLabel(applicationInfo).toString()
+
+            AppInfo(packageName, appName)
+        } catch (e: PackageManager.NameNotFoundException) {
+            null
+        }
+    }
+
+    /**
+     * Get the list of application IDs of supported apps excluding our own app.
+     */
+    private val supportedApps: Sequence<String>
+        get() {
+            val myPackageName = context.packageName
+            return SUPPORTED_APPS
+                .asSequence()
+                .filterNot { packageName -> packageName == myPackageName }
+        }
+
+    companion object {
+        private val SUPPORTED_APPS = listOf(
+            // K-9 Mail
+            "com.fsck.k9",
+            // Thunderbird for Android (release)
+            "net.thunderbird.android",
+            // Thunderbird for Android (beta)
+            "net.thunderbird.android.beta",
+            // Thunderbird for Android (daily)
+            "net.thunderbird.android.daily",
+        )
+    }
+}
+
+internal data class AppInfo(val packageName: String, private val appName: String) {
+    // ArrayAdapter is using `toString()` when rendering list items. See PickAppDialogFragment.
+    override fun toString() = appName
+}
diff --git a/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/PickAppDialogFragment.kt b/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/PickAppDialogFragment.kt
new file mode 100644
index 00000000000..7fdfd0ad3bf
--- /dev/null
+++ b/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/PickAppDialogFragment.kt
@@ -0,0 +1,73 @@
+package app.k9mail.feature.settings.import.ui
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.content.DialogInterface.OnClickListener
+import android.os.Bundle
+import android.widget.ArrayAdapter
+import androidx.core.os.bundleOf
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.setFragmentResult
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import app.k9mail.feature.settings.importing.R
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.FlowCollector
+import kotlinx.coroutines.launch
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import com.fsck.k9.ui.base.R as BaseR
+
+/**
+ * A dialog that allows the user to pick an app from which to import settings/accounts.
+ */
+internal class PickAppDialogFragment : DialogFragment() {
+    private val viewModel: PickAppViewModel by viewModel()
+
+    private var selectedPackageName: String? = null
+
+    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+        val adapter = ArrayAdapter<AppInfo>(
+            requireContext(),
+            R.layout.settings_import_pick_app_list_item,
+            R.id.settings_import_app_name,
+        )
+
+        viewModel.appInfoFlow.observe(this) { appInfoList ->
+            adapter.clear()
+            adapter.addAll(appInfoList)
+            adapter.notifyDataSetChanged()
+        }
+
+        val clickListener = OnClickListener { _, index ->
+            val packageName = adapter.getItem(index)?.packageName ?: return@OnClickListener
+            selectedPackageName = packageName
+        }
+
+        return MaterialAlertDialogBuilder(requireContext())
+            .setTitle(R.string.settings_import_pick_app_dialog_title)
+            .setAdapter(adapter, clickListener)
+            .setNegativeButton(BaseR.string.cancel_action, null)
+            .create()
+    }
+
+    override fun onDismiss(dialog: DialogInterface) {
+        setFragmentResult(FRAGMENT_RESULT_KEY, bundleOf(FRAGMENT_RESULT_APP to selectedPackageName))
+        super.onDismiss(dialog)
+    }
+
+    companion object {
+        const val FRAGMENT_RESULT_KEY = "pickApp"
+        const val FRAGMENT_RESULT_APP = "packageName"
+    }
+}
+
+private fun <T> Flow<T>.observe(lifecycleOwner: LifecycleOwner, collector: FlowCollector<T>) {
+    lifecycleOwner.lifecycleScope.launch {
+        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+            collect(collector)
+        }
+    }
+}
diff --git a/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/PickAppViewModel.kt b/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/PickAppViewModel.kt
new file mode 100644
index 00000000000..25d99c519f5
--- /dev/null
+++ b/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/PickAppViewModel.kt
@@ -0,0 +1,28 @@
+package app.k9mail.feature.settings.import.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+
+internal class PickAppViewModel(
+    private val importAppFetcher: ImportAppFetcher,
+    private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO,
+) : ViewModel() {
+    private val _appInfoFlow = MutableStateFlow<List<AppInfo>>(emptyList())
+    val appInfoFlow: StateFlow<List<AppInfo>> = _appInfoFlow
+
+    init {
+        fetchImportApps()
+    }
+
+    private fun fetchImportApps() {
+        viewModelScope.launch(backgroundDispatcher) {
+            val appInfoList = importAppFetcher.getAppInfoList()
+            _appInfoFlow.emit(appInfoList)
+        }
+    }
+}
diff --git a/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/SettingsImportFragment.kt b/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/SettingsImportFragment.kt
index 2e8b231ba78..8246977c051 100644
--- a/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/SettingsImportFragment.kt
+++ b/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/SettingsImportFragment.kt
@@ -30,6 +30,18 @@ class SettingsImportFragment : Fragment() {
     private lateinit var itemAdapter: ItemAdapter<ImportListItem<*>>

     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+        parentFragmentManager.setFragmentResultListener(
+            PickAppDialogFragment.FRAGMENT_RESULT_KEY,
+            viewLifecycleOwner,
+        ) { _, result: Bundle ->
+            val packageName = result.getString(PickAppDialogFragment.FRAGMENT_RESULT_APP)
+            if (packageName != null) {
+                viewModel.onAppPicked(packageName)
+            } else {
+                viewModel.onAppPickCanceled()
+            }
+        }
+
         return inflater.inflate(R.layout.fragment_settings_import, container, false)
     }

@@ -42,6 +54,7 @@ class SettingsImportFragment : Fragment() {

         initializeSettingsImportList(viewHolder.settingsImportList)
         viewHolder.pickDocumentButton.setOnClickListener { viewModel.onPickDocumentButtonClicked() }
+        viewHolder.pickAppButton.setOnClickListener { viewModel.onPickAppButtonClicked() }
         viewHolder.importButton.setOnClickListener { viewModel.onImportButtonClicked() }
         viewHolder.closeButton.setOnClickListener { viewModel.onCloseButtonClicked() }

@@ -92,6 +105,8 @@ class SettingsImportFragment : Fragment() {
         settingsImportList.isVisible = model.isSettingsListVisible
         pickDocumentButton.isInvisible = !model.isPickDocumentButtonVisible
         pickDocumentButton.isEnabled = model.isPickDocumentButtonEnabled
+        pickAppButton.isInvisible = !model.isPickAppButtonVisible
+        pickAppButton.isEnabled = model.isPickAppButtonEnabled
         loadingProgressBar.isVisible = model.isLoadingProgressVisible
         importProgressBar.isVisible = model.isImportProgressVisible

@@ -158,6 +173,7 @@ class SettingsImportFragment : Fragment() {
         when (action) {
             is Action.Close -> closeImportScreen(action)
             is Action.PickDocument -> pickDocument()
+            is Action.PickApp -> pickApp()
             is Action.PasswordPrompt -> showPasswordPrompt(action)
             is Action.StartAuthorization -> startAuthorization(action)
         }
@@ -182,6 +198,10 @@ class SettingsImportFragment : Fragment() {
         startActivityForResult(createDocumentIntent, REQUEST_PICK_DOCUMENT)
     }

+    private fun pickApp() {
+        PickAppDialogFragment().show(parentFragmentManager, "pick_app")
+    }
+
     private fun startAuthorization(action: Action.StartAuthorization) {
         val intent = OAuthFlowActivity.buildLaunchIntent(
             context = requireContext(),
@@ -262,6 +282,7 @@ class SettingsImportFragment : Fragment() {

 private class ViewHolder(view: View) {
     val pickDocumentButton: View = view.findViewById(R.id.pickDocumentButton)
+    val pickAppButton: View = view.findViewById(R.id.pickAppButton)
     val importButton: View = view.findViewById(R.id.importButton)
     val closeButton: MaterialButton = view.findViewById(R.id.closeButton)
     val loadingProgressBar: View = view.findViewById(R.id.loadingProgressBar)
diff --git a/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/SettingsImportUiModel.kt b/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/SettingsImportUiModel.kt
index ef819e74057..1804ec49575 100644
--- a/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/SettingsImportUiModel.kt
+++ b/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/SettingsImportUiModel.kt
@@ -11,6 +11,9 @@ internal class SettingsImportUiModel {
     var closeButtonLabel: CloseButtonLabel = CloseButtonLabel.OK
     var isPickDocumentButtonVisible = true
     var isPickDocumentButtonEnabled = true
+    var isPickAppButtonVisible = true
+    var isPickAppButtonEnabled = false
+    var isPickAppButtonPermanentlyDisabled = true
     var isLoadingProgressVisible = false
     var isImportProgressVisible = false
     var statusText = StatusText.HIDDEN
@@ -24,13 +27,21 @@ internal class SettingsImportUiModel {
     val wasAccountImportSuccessful
         get() = hasImportStarted && settingsList.any { it !is GeneralSettings && it.importStatus.isSuccess }

-    fun enablePickDocumentButton() {
+    fun enablePickButtons() {
         isPickDocumentButtonEnabled = true
+        maybeEnablePickAppButton()
     }

-    fun disablePickDocumentButton() {
+    private fun maybeEnablePickAppButton() {
+        if (!isPickAppButtonPermanentlyDisabled) {
+            isPickAppButtonEnabled = true
+        }
+    }
+
+    fun disablePickButtons() {
         statusText = StatusText.HIDDEN
         isPickDocumentButtonEnabled = false
+        isPickAppButtonEnabled = false
     }

     private fun enableImportButton() {
@@ -47,6 +58,7 @@ internal class SettingsImportUiModel {
     fun showLoadingProgress() {
         isLoadingProgressVisible = true
         isPickDocumentButtonVisible = false
+        isPickAppButtonVisible = false
         isSettingsListEnabled = false
         statusText = StatusText.HIDDEN
     }
@@ -80,6 +92,8 @@ internal class SettingsImportUiModel {
         isLoadingProgressVisible = false
         isPickDocumentButtonVisible = true
         isPickDocumentButtonEnabled = true
+        isPickAppButtonVisible = true
+        maybeEnablePickAppButton()
         statusText = StatusText.IMPORT_READ_FAILURE
         importButton = ButtonState.DISABLED
     }
@@ -90,6 +104,8 @@ internal class SettingsImportUiModel {
         isSettingsListVisible = false
         isPickDocumentButtonVisible = true
         isPickDocumentButtonEnabled = true
+        isPickAppButtonVisible = true
+        maybeEnablePickAppButton()
         statusText = StatusText.IMPORT_FAILURE
         importButton = ButtonState.DISABLED
     }
@@ -108,6 +124,7 @@ internal class SettingsImportUiModel {
         isSettingsListVisible = true
         isLoadingProgressVisible = false
         isPickDocumentButtonVisible = false
+        isPickAppButtonVisible = false
         updateImportButtonFromSelection()
     }

diff --git a/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/SettingsImportViewModel.kt b/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/SettingsImportViewModel.kt
index 6da32f2790f..0c425da4aa1 100644
--- a/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/SettingsImportViewModel.kt
+++ b/feature/settings/import/src/main/kotlin/app/k9mail/feature/settings/import/ui/SettingsImportViewModel.kt
@@ -1,9 +1,10 @@
 package app.k9mail.feature.settings.import.ui

-import android.content.Context
+import android.content.ContentResolver
 import android.net.Uri
 import android.os.Bundle
 import android.os.Parcelable
+import androidx.core.net.toUri
 import androidx.core.os.BundleCompat
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
@@ -17,8 +18,11 @@ import com.fsck.k9.preferences.ImportResults
 import com.fsck.k9.preferences.SettingsImporter
 import com.fsck.k9.ui.base.bundle.getEnum
 import com.fsck.k9.ui.base.bundle.putEnum
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.SupervisorJob
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
@@ -29,10 +33,13 @@ private typealias AccountUuid = String
 private typealias AccountNumber = Int

 internal class SettingsImportViewModel(
-    private val context: Context,
+    private val contentResolver: ContentResolver,
     private val settingsImporter: SettingsImporter,
     private val accountActivator: AccountActivator,
-) : ViewModel() {
+    private val importAppFetcher: ImportAppFetcher,
+    private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO,
+    viewModelScope: CoroutineScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()),
+) : ViewModel(viewModelScope) {
     private val uiModelLiveData = MutableLiveData<SettingsImportUiModel>()
     private val actionLiveData = SingleLiveEvent<Action>()

@@ -62,6 +69,25 @@ internal class SettingsImportViewModel(
                 .toSet()
         }

+    init {
+        checkForImportApps()
+    }
+
+    private fun checkForImportApps() {
+        viewModelScope.launch {
+            val isAtLeastOneAppInstalled = withContext(backgroundDispatcher) {
+                importAppFetcher.isAtLeastOneAppInstalled()
+            }
+
+            updateUiModel {
+                if (isAtLeastOneAppInstalled) {
+                    isPickAppButtonPermanentlyDisabled = false
+                    isPickAppButtonEnabled = isPickDocumentButtonEnabled
+                }
+            }
+        }
+    }
+
     fun getActionEvents(): LiveData<Action> = actionLiveData

     fun getUiModel(): LiveData<SettingsImportUiModel> {
@@ -169,15 +195,23 @@ internal class SettingsImportViewModel(

     fun onPickDocumentButtonClicked() {
         updateUiModel {
-            disablePickDocumentButton()
+            disablePickButtons()
         }

         sendActionEvent(Action.PickDocument)
     }

+    fun onPickAppButtonClicked() {
+        updateUiModel {
+            disablePickButtons()
+        }
+
+        sendActionEvent(Action.PickApp)
+    }
+
     fun onDocumentPickCanceled() {
         updateUiModel {
-            enablePickDocumentButton()
+            enablePickButtons()
         }
     }

@@ -189,6 +223,20 @@ internal class SettingsImportViewModel(
         startReadSettingsFile(contentUri)
     }

+    fun onAppPickCanceled() {
+        updateUiModel {
+            enablePickButtons()
+        }
+    }
+
+    fun onAppPicked(packageName: String) {
+        updateUiModel {
+            showLoadingProgress()
+        }
+
+        startReadSettingsFile("content://$packageName.settings/".toUri())
+    }
+
     fun onImportButtonClicked() {
         updateUiModel {
             showImportingProgress()
@@ -228,7 +276,7 @@ internal class SettingsImportViewModel(
             updateCloseButtonAndImportStatusText()
         }

-        viewModelScope.launch(Dispatchers.IO) {
+        viewModelScope.launch(backgroundDispatcher) {
             withContext(NonCancellable) {
                 with(result) {
                     accountActivator.enableAccount(accountUuid, incomingServerPassword, outgoingServerPassword)
@@ -246,7 +294,7 @@ internal class SettingsImportViewModel(
                 updateCloseButtonAndImportStatusText()
             }

-            viewModelScope.launch(Dispatchers.IO) {
+            viewModelScope.launch(backgroundDispatcher) {
                 withContext(NonCancellable) {
                     accountActivator.enableAccount(accountUuid)
                 }
@@ -266,7 +314,7 @@ internal class SettingsImportViewModel(
         viewModelScope.launch {
             try {
                 val (elapsed, contents) = measureRealtimeMillisWithResult {
-                    withContext(Dispatchers.IO) {
+                    withContext(backgroundDispatcher) {
                         readSettings(contentUri)
                     }
                 }
@@ -310,7 +358,7 @@ internal class SettingsImportViewModel(
                 val importAccounts = selectedAccounts.toList()

                 val (elapsed, importResults) = measureRealtimeMillisWithResult {
-                    withContext(Dispatchers.IO) {
+                    withContext(backgroundDispatcher) {
                         importSettings(contentUri, importGeneralSettings, importAccounts)
                     }
                 }
@@ -387,7 +435,7 @@ internal class SettingsImportViewModel(
     }

     private fun readSettings(contentUri: Uri): ImportContents {
-        val inputStream = context.contentResolver.openInputStream(contentUri)
+        val inputStream = contentResolver.openInputStream(contentUri)
             ?: error("Failed to open settings file for reading: $contentUri")

         return inputStream.use {
@@ -396,7 +444,7 @@ internal class SettingsImportViewModel(
     }

     private fun importSettings(contentUri: Uri, generalSettings: Boolean, accounts: List<AccountUuid>): ImportResults {
-        val inputStream = context.contentResolver.openInputStream(contentUri)
+        val inputStream = contentResolver.openInputStream(contentUri)
             ?: error("Failed to open settings file for reading: $contentUri")

         return inputStream.use {
@@ -486,6 +534,7 @@ internal class SettingsImportViewModel(
 sealed class Action {
     class Close(val importSuccess: Boolean) : Action()
     object PickDocument : Action()
+    object PickApp : Action()
     class StartAuthorization(val accountUuid: String) : Action()
     class PasswordPrompt(
         val accountUuid: String,
diff --git a/feature/settings/import/src/main/res/layout/fragment_settings_import.xml b/feature/settings/import/src/main/res/layout/fragment_settings_import.xml
index 1f1d79f85c9..a927b1ef567 100644
--- a/feature/settings/import/src/main/res/layout/fragment_settings_import.xml
+++ b/feature/settings/import/src/main/res/layout/fragment_settings_import.xml
@@ -31,10 +31,26 @@
         android:layout_marginEnd="16dp"
         android:layout_marginBottom="16dp"
         android:text="@string/settings_import_pick_document_button"
-        app:layout_constraintBottom_toTopOf="@+id/bottomBar"
+        app:layout_constraintBottom_toTopOf="@+id/pickAppButton"
         app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintHorizontal_bias="0.5"
         app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/settingsImportList"
+        />
+
+    <com.google.android.material.button.MaterialButton
+        android:id="@+id/pickAppButton"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginStart="16dp"
+        android:layout_marginEnd="16dp"
+        android:layout_marginBottom="16dp"
+        android:text="@string/settings_import_pick_app_button"
+        app:layout_constraintBottom_toTopOf="@+id/settingsImportList"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintHorizontal_bias="0.5"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/pickDocumentButton"
         />

     <ProgressBar
diff --git a/feature/settings/import/src/main/res/layout/settings_import_pick_app_list_item.xml b/feature/settings/import/src/main/res/layout/settings_import_pick_app_list_item.xml
new file mode 100644
index 00000000000..0f49365e656
--- /dev/null
+++ b/feature/settings/import/src/main/res/layout/settings_import_pick_app_list_item.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:gravity="center_vertical"
+    android:minHeight="56dp"
+    android:paddingHorizontal="24dp"
+    android:paddingVertical="8dp"
+    >
+
+    <com.google.android.material.textview.MaterialTextView
+        android:id="@+id/settings_import_app_name"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:textAppearance="?attr/textAppearanceBodyLarge"
+        tools:text="K-9 Mail"
+        />
+
+</LinearLayout>
diff --git a/feature/settings/import/src/main/res/values/strings.xml b/feature/settings/import/src/main/res/values/strings.xml
index 1f53a86f21e..6a7d9f44c38 100644
--- a/feature/settings/import/src/main/res/values/strings.xml
+++ b/feature/settings/import/src/main/res/values/strings.xml
@@ -2,6 +2,8 @@
 <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
     <string name="settings_import_title">Import settings</string>
     <string name="settings_import_pick_document_button">Select file</string>
+    <string name="settings_import_pick_app_button">Import from app</string>
+    <string name="settings_import_pick_app_dialog_title">Import from app</string>
     <string name="settings_import_button">Import</string>
     <string name="settings_import_success_generic">Settings successfully imported</string>
     <!-- Displayed after importing accounts when one or multiple accounts require entering a password -->

diff --git a/feature/settings/import/src/test/kotlin/app/k9mail/feature/settings/import/ui/ImportAppFetcherTest.kt b/feature/settings/import/src/test/kotlin/app/k9mail/feature/settings/import/ui/ImportAppFetcherTest.kt
new file mode 100644
index 00000000000..a0292252fc7
--- /dev/null
+++ b/feature/settings/import/src/test/kotlin/app/k9mail/feature/settings/import/ui/ImportAppFetcherTest.kt
@@ -0,0 +1,97 @@
+package app.k9mail.feature.settings.import.ui
+
+import android.app.Application
+import android.content.Context
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageInfo
+import androidx.test.core.app.ApplicationProvider
+import assertk.assertThat
+import assertk.assertions.containsExactly
+import assertk.assertions.containsExactlyInAnyOrder
+import assertk.assertions.isEmpty
+import assertk.assertions.isFalse
+import assertk.assertions.isTrue
+import kotlin.test.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+
+private const val MY_PACKAGE_NAME = "net.thunderbird.android"
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class)
+class ImportAppFetcherTest {
+    private val appContext = ApplicationProvider.getApplicationContext<Context>()
+    private val context = createPackageContext()
+    private val importAppFetcher = ImportAppFetcher(context)
+
+    @Test
+    fun `isAtLeastOneAppInstalled() without any other apps installed`() {
+        val result = importAppFetcher.isAtLeastOneAppInstalled()
+
+        assertThat(result).isFalse()
+    }
+
+    @Test
+    fun `isAtLeastOneAppInstalled() with another app installed`() {
+        installPackage("com.fsck.k9")
+
+        val result = importAppFetcher.isAtLeastOneAppInstalled()
+
+        assertThat(result).isTrue()
+    }
+
+    @Test
+    fun `getAppInfoList() without any other apps installed`() {
+        val result = importAppFetcher.getAppInfoList()
+
+        assertThat(result).isEmpty()
+    }
+
+    @Test
+    fun `getAppInfoList() with another app installed`() {
+        installPackage("com.fsck.k9", "K-9 Mail")
+
+        val result = importAppFetcher.getAppInfoList()
+
+        assertThat(result).containsExactly(
+            AppInfo("com.fsck.k9", "K-9 Mail"),
+        )
+    }
+
+    @Test
+    fun `getAppInfoList() with multiple other apps installed`() {
+        installPackage("com.fsck.k9", "K-9 Mail")
+        installPackage("net.thunderbird.android.beta", "Thunderbird Beta")
+        installPackage("net.thunderbird.android.daily", "Thunderbird Daily")
+
+        val result = importAppFetcher.getAppInfoList()
+
+        assertThat(result).containsExactlyInAnyOrder(
+            AppInfo("com.fsck.k9", "K-9 Mail"),
+            AppInfo("net.thunderbird.android.beta", "Thunderbird Beta"),
+            AppInfo("net.thunderbird.android.daily", "Thunderbird Daily"),
+        )
+    }
+
+    private fun createPackageContext(): Context {
+        // Robolectric doesn't easily support setting the package name of the test app. So we create a Context instance
+        // whose getPackageName() method will return the desired package name.
+        installPackage(MY_PACKAGE_NAME, "Test App")
+        return appContext.createPackageContext(MY_PACKAGE_NAME, 0)
+    }
+
+    private fun installPackage(packageName: String, name: String = "irrelevant") {
+        val packageManager = shadowOf(appContext.packageManager)
+
+        packageManager.installPackage(
+            PackageInfo().apply {
+                this.packageName = packageName
+                this.applicationInfo = ApplicationInfo().apply {
+                    this.name = name
+                }
+            },
+        )
+    }
+}
diff --git a/feature/settings/import/src/test/kotlin/app/k9mail/feature/settings/import/ui/SettingsImportViewModelTest.kt b/feature/settings/import/src/test/kotlin/app/k9mail/feature/settings/import/ui/SettingsImportViewModelTest.kt
new file mode 100644
index 00000000000..01e811302ec
--- /dev/null
+++ b/feature/settings/import/src/test/kotlin/app/k9mail/feature/settings/import/ui/SettingsImportViewModelTest.kt
@@ -0,0 +1,268 @@
+package app.k9mail.feature.settings.import.ui
+
+import android.app.Application
+import android.content.ContentResolver
+import androidx.core.net.toUri
+import app.k9mail.feature.settings.import.SettingsImportExternalContract.AccountActivator
+import assertk.all
+import assertk.assertThat
+import assertk.assertions.hasSize
+import assertk.assertions.index
+import assertk.assertions.isEqualTo
+import assertk.assertions.isFalse
+import assertk.assertions.isInstanceOf
+import assertk.assertions.isTrue
+import assertk.assertions.prop
+import com.fsck.k9.preferences.AccountDescription
+import com.fsck.k9.preferences.AccountDescriptionPair
+import com.fsck.k9.preferences.ImportContents
+import com.fsck.k9.preferences.ImportResults
+import com.fsck.k9.preferences.SettingsImportExportException
+import com.fsck.k9.preferences.SettingsImporter
+import kotlin.test.Test
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.TestScope
+import org.junit.runner.RunWith
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.doThrow
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.stub
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class)
+class SettingsImportViewModelTest {
+    private val testScope = TestScope()
+    private val contentResolver = mock<ContentResolver>()
+    private val settingsImporter = mock<SettingsImporter>()
+    private val accountActivator = mock<AccountActivator>()
+    private val importAppFetcher = mock<ImportAppFetcher>()
+    private val viewModel = SettingsImportViewModel(
+        contentResolver = contentResolver,
+        settingsImporter = settingsImporter,
+        accountActivator = accountActivator,
+        importAppFetcher = importAppFetcher,
+        backgroundDispatcher = Dispatchers.Unconfined,
+        viewModelScope = testScope,
+    )
+
+    @Test
+    fun `pickAppButton should only be enabled after app from which we can import has been found`() {
+        importAppFetcher.stub {
+            on { isAtLeastOneAppInstalled() } doReturn true
+        }
+        val uiModel = viewModel.getUiModel().value!!
+
+        assertThat(uiModel.isPickAppButtonVisible).isTrue()
+        assertThat(uiModel.isPickAppButtonEnabled).isFalse()
+
+        // Check for apps we can import from
+        testScope.testScheduler.advanceUntilIdle()
+
+        assertThat(uiModel.isPickAppButtonVisible).isTrue()
+        assertThat(uiModel.isPickAppButtonEnabled).isTrue()
+    }
+
+    @Test
+    fun `pickAppButton should remain disabled when no app from which we can import has been found`() {
+        importAppFetcher.stub {
+            on { isAtLeastOneAppInstalled() } doReturn false
+        }
+        val uiModelLiveData = viewModel.getUiModel()
+
+        assertThat(uiModelLiveData.value!!.isPickAppButtonVisible).isTrue()
+        assertThat(uiModelLiveData.value!!.isPickAppButtonEnabled).isFalse()
+
+        // Check for apps we can import from
+        testScope.testScheduler.advanceUntilIdle()
+
+        assertThat(uiModelLiveData.value!!.isPickAppButtonVisible).isTrue()
+        assertThat(uiModelLiveData.value!!.isPickAppButtonEnabled).isFalse()
+    }
+
+    @Test
+    fun `clicking pickAppButton should disable buttons and emit PickApp event`() {
+        importAppFetcher.stub {
+            on { isAtLeastOneAppInstalled() } doReturn true
+        }
+        val uiModelLiveData = viewModel.getUiModel()
+        val actionEventsLiveData = viewModel.getActionEvents()
+
+        // Check for apps we can import from
+        testScope.testScheduler.advanceUntilIdle()
+
+        viewModel.onPickAppButtonClicked()
+
+        assertThat(uiModelLiveData.value!!.isPickDocumentButtonEnabled).isFalse()
+        assertThat(uiModelLiveData.value!!.isPickAppButtonEnabled).isFalse()
+        assertThat(actionEventsLiveData.value).isEqualTo(Action.PickApp)
+    }
+
+    @Test
+    fun `canceling picking an app should enable buttons`() {
+        importAppFetcher.stub {
+            on { isAtLeastOneAppInstalled() } doReturn true
+        }
+        val uiModelLiveData = viewModel.getUiModel()
+
+        // Check for apps we can import from
+        testScope.testScheduler.advanceUntilIdle()
+
+        viewModel.onPickAppButtonClicked()
+        viewModel.onAppPickCanceled()
+
+        assertThat(uiModelLiveData.value!!.isPickDocumentButtonEnabled).isTrue()
+        assertThat(uiModelLiveData.value!!.isPickAppButtonEnabled).isTrue()
+    }
+
+    @Test
+    fun `picking an app should show loading progress, then settings list`() {
+        importAppFetcher.stub {
+            on { isAtLeastOneAppInstalled() } doReturn true
+        }
+        val uiModelLiveData = viewModel.getUiModel()
+
+        // Check for apps we can import from
+        testScope.testScheduler.advanceUntilIdle()
+
+        viewModel.onPickAppButtonClicked()
+        viewModel.onAppPicked("net.thunderbird.android")
+
+        assertThat(uiModelLiveData.value!!.isLoadingProgressVisible).isTrue()
+        assertThat(uiModelLiveData.value!!.isPickDocumentButtonVisible).isFalse()
+        assertThat(uiModelLiveData.value!!.isPickAppButtonVisible).isFalse()
+        assertThat(uiModelLiveData.value!!.isSettingsListVisible).isFalse()
+
+        val inputStream = "".byteInputStream()
+        contentResolver.stub {
+            on { openInputStream("content://net.thunderbird.android.settings/".toUri()) } doReturn inputStream
+        }
+        settingsImporter.stub {
+            on { getImportStreamContents(inputStream) } doReturn ImportContents(
+                globalSettings = false,
+                accounts = listOf(
+                    AccountDescription(name = "Account 1", uuid = "uuid-1"),
+                    AccountDescription(name = "Account 2", uuid = "uuid-2"),
+                ),
+            )
+        }
+
+        // Read settings data from content provider
+        testScope.testScheduler.advanceUntilIdle()
+
+        assertThat(uiModelLiveData.value!!.isLoadingProgressVisible).isFalse()
+        assertThat(uiModelLiveData.value!!.isPickDocumentButtonVisible).isFalse()
+        assertThat(uiModelLiveData.value!!.isPickAppButtonVisible).isFalse()
+        assertThat(uiModelLiveData.value!!.isSettingsListVisible).isTrue()
+        assertThat(uiModelLiveData.value!!.settingsList).all {
+            hasSize(2)
+            index(0).isInstanceOf<SettingsListItem.Account>()
+                .prop(SettingsListItem.Account::displayName).isEqualTo("Account 1")
+            index(1).isInstanceOf<SettingsListItem.Account>()
+                .prop(SettingsListItem.Account::displayName).isEqualTo("Account 2")
+        }
+    }
+
+    @Test
+    fun `failure to import settings from an app should show error message`() {
+        importAppFetcher.stub {
+            on { isAtLeastOneAppInstalled() } doReturn true
+        }
+        val uiModelLiveData = viewModel.getUiModel()
+
+        // Check for apps we can import from
+        testScope.testScheduler.advanceUntilIdle()
+
+        viewModel.onPickAppButtonClicked()
+        viewModel.onAppPicked("net.thunderbird.android")
+
+        val inputStream = "".byteInputStream()
+        contentResolver.stub {
+            on { openInputStream("content://net.thunderbird.android.settings/".toUri()) } doReturn inputStream
+        }
+        settingsImporter.stub {
+            on { getImportStreamContents(inputStream) } doThrow SettingsImportExportException()
+        }
+
+        // Read settings data from content provider
+        testScope.testScheduler.advanceUntilIdle()
+
+        assertThat(uiModelLiveData.value!!.isLoadingProgressVisible).isFalse()
+        assertThat(uiModelLiveData.value!!.isPickDocumentButtonVisible).isTrue()
+        assertThat(uiModelLiveData.value!!.isPickAppButtonVisible).isTrue()
+        assertThat(uiModelLiveData.value!!.isSettingsListVisible).isFalse()
+        assertThat(uiModelLiveData.value!!.statusText).isEqualTo(StatusText.IMPORT_READ_FAILURE)
+    }
+
+    @Test
+    fun `successfully importing settings from an app should show success message`() {
+        importAppFetcher.stub {
+            on { isAtLeastOneAppInstalled() } doReturn true
+        }
+        val uiModelLiveData = viewModel.getUiModel()
+
+        // Check for apps we can import from
+        testScope.testScheduler.advanceUntilIdle()
+
+        viewModel.onPickAppButtonClicked()
+        viewModel.onAppPicked("net.thunderbird.android")
+
+        val inputStream = "".byteInputStream()
+        contentResolver.stub {
+            on { openInputStream("content://net.thunderbird.android.settings/".toUri()) } doReturn inputStream
+        }
+        val accountOne = AccountDescription(name = "Account 1", uuid = "uuid-1")
+        val accountTwo = AccountDescription(name = "Account 2", uuid = "uuid-2")
+        settingsImporter.stub {
+            on { getImportStreamContents(inputStream) } doReturn ImportContents(
+                globalSettings = false,
+                accounts = listOf(
+                    accountOne,
+                    accountTwo,
+                ),
+            )
+        }
+
+        // Read settings data from content provider
+        testScope.testScheduler.advanceUntilIdle()
+
+        // Deselect second account
+        viewModel.onSettingsListItemClicked(1)
+
+        viewModel.onImportButtonClicked()
+
+        assertThat(uiModelLiveData.value!!.isImportProgressVisible).isTrue()
+        assertThat(uiModelLiveData.value!!.isSettingsListEnabled).isFalse()
+        assertThat(uiModelLiveData.value!!.importButton).isEqualTo(ButtonState.INVISIBLE)
+        assertThat(uiModelLiveData.value!!.statusText).isEqualTo(StatusText.IMPORTING_PROGRESS)
+
+        settingsImporter.stub {
+            on { importSettings(inputStream, false, listOf("uuid-1")) } doReturn ImportResults(
+                globalSettings = false,
+                importedAccounts = listOf(
+                    AccountDescriptionPair(
+                        original = accountOne,
+                        imported = accountOne,
+                        authorizationNeeded = false,
+                        incomingPasswordNeeded = false,
+                        outgoingPasswordNeeded = false,
+                        incomingServerName = "irrelevant",
+                        outgoingServerName = "irrelevant",
+                    ),
+                ),
+                erroneousAccounts = emptyList(),
+            )
+        }
+
+        // Import settings
+        testScope.testScheduler.advanceUntilIdle()
+
+        assertThat(uiModelLiveData.value!!.isImportProgressVisible).isFalse()
+        assertThat(uiModelLiveData.value!!.isSettingsListEnabled).isTrue()
+        assertThat(uiModelLiveData.value!!.importButton).isEqualTo(ButtonState.GONE)
+        assertThat(uiModelLiveData.value!!.closeButton).isEqualTo(ButtonState.ENABLED)
+        assertThat(uiModelLiveData.value!!.closeButtonLabel).isEqualTo(CloseButtonLabel.OK)
+        assertThat(uiModelLiveData.value!!.statusText).isEqualTo(StatusText.IMPORT_SUCCESS)
+    }
+}