Repository: thunderbird/thunderbird-android (language: kotlin)

Issue:
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.


Discussion hints:
nan

Pull request: 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

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

Fix patch:
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 -->


Test patch:
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)
+    }
+}