diff --git a/app/core/src/main/java/com/fsck/k9/preferences/ImportResults.kt b/app/core/src/main/java/com/fsck/k9/preferences/ImportResults.kt index 31aa3727d..1e3320d51 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/ImportResults.kt +++ b/app/core/src/main/java/com/fsck/k9/preferences/ImportResults.kt @@ -1,23 +1,22 @@ package com.fsck.k9.preferences data class ImportResults( - @JvmField val globalSettings: Boolean, - @JvmField val importedAccounts: List, - @JvmField val erroneousAccounts: List, + val globalSettings: Boolean, + val importedAccounts: List, + val erroneousAccounts: List, ) data class AccountDescriptionPair( - @JvmField val original: AccountDescription, - @JvmField val imported: AccountDescription, - @JvmField val overwritten: Boolean, - @JvmField val authorizationNeeded: Boolean, - @JvmField val incomingPasswordNeeded: Boolean, - @JvmField val outgoingPasswordNeeded: Boolean, - @JvmField val incomingServerName: String, - @JvmField val outgoingServerName: String, + val original: AccountDescription, + val imported: AccountDescription, + val authorizationNeeded: Boolean, + val incomingPasswordNeeded: Boolean, + val outgoingPasswordNeeded: Boolean, + val incomingServerName: String, + val outgoingServerName: String, ) data class AccountDescription( - @JvmField val name: String, - @JvmField val uuid: String, + val name: String, + val uuid: String, ) diff --git a/app/core/src/main/java/com/fsck/k9/preferences/KoinModule.kt b/app/core/src/main/java/com/fsck/k9/preferences/KoinModule.kt index 344d28805..659921cc6 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/KoinModule.kt +++ b/app/core/src/main/java/com/fsck/k9/preferences/KoinModule.kt @@ -23,4 +23,17 @@ val preferencesModule = module { coroutineScope = get(named("AppCoroutineScope")), ) } bind GeneralSettingsManager::class + + factory { SettingsFileParser() } + factory { + SettingsImporter( + settingsFileParser = get(), + preferences = get(), + generalSettingsManager = get(), + localFoldersCreator = get(), + serverSettingsSerializer = get(), + clock = get(), + context = get(), + ) + } } diff --git a/app/core/src/main/java/com/fsck/k9/preferences/SettingsFileContents.kt b/app/core/src/main/java/com/fsck/k9/preferences/SettingsFileContents.kt index 6d7c08e79..ae69d5711 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/SettingsFileContents.kt +++ b/app/core/src/main/java/com/fsck/k9/preferences/SettingsFileContents.kt @@ -3,45 +3,45 @@ package com.fsck.k9.preferences import com.fsck.k9.mail.AuthType internal data class Imported( - @JvmField val contentVersion: Int, - @JvmField val globalSettings: ImportedSettings?, - @JvmField val accounts: Map?, + val contentVersion: Int, + val globalSettings: ImportedSettings?, + val accounts: Map?, ) internal data class ImportedSettings( - @JvmField val settings: Map = emptyMap(), + val settings: Map = emptyMap(), ) internal data class ImportedAccount( - @JvmField val uuid: String, - @JvmField val name: String?, - @JvmField val incoming: ImportedServer?, - @JvmField val outgoing: ImportedServer?, - @JvmField val settings: ImportedSettings?, - @JvmField val identities: List?, - @JvmField val folders: List?, + val uuid: String, + val name: String?, + val incoming: ImportedServer?, + val outgoing: ImportedServer?, + val settings: ImportedSettings?, + val identities: List?, + val folders: List?, ) internal data class ImportedServer( - @JvmField val type: String?, - @JvmField val host: String?, - @JvmField val port: String?, - @JvmField val connectionSecurity: String?, - @JvmField val authenticationType: AuthType?, - @JvmField val username: String?, - @JvmField val password: String?, - @JvmField val clientCertificateAlias: String?, - @JvmField val extras: ImportedSettings?, + val type: String?, + val host: String?, + val port: String?, + val connectionSecurity: String?, + val authenticationType: AuthType?, + val username: String?, + val password: String?, + val clientCertificateAlias: String?, + val extras: ImportedSettings?, ) internal data class ImportedIdentity( - @JvmField val name: String?, - @JvmField val email: String?, - @JvmField val description: String?, - @JvmField val settings: ImportedSettings?, + val name: String?, + val email: String?, + val description: String?, + val settings: ImportedSettings?, ) internal data class ImportedFolder( - @JvmField val name: String?, - @JvmField val settings: ImportedSettings?, + val name: String?, + val settings: ImportedSettings?, ) diff --git a/app/core/src/main/java/com/fsck/k9/preferences/SettingsImporter.java b/app/core/src/main/java/com/fsck/k9/preferences/SettingsImporter.java deleted file mode 100644 index 08e86c739..000000000 --- a/app/core/src/main/java/com/fsck/k9/preferences/SettingsImporter.java +++ /dev/null @@ -1,612 +0,0 @@ -package com.fsck.k9.preferences; - - -import java.io.InputStream; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -import android.content.Context; -import android.content.SharedPreferences; -import android.text.TextUtils; - -import com.fsck.k9.Account; -import com.fsck.k9.AccountPreferenceSerializer; -import com.fsck.k9.Core; -import com.fsck.k9.DI; -import com.fsck.k9.Identity; -import com.fsck.k9.K9; -import com.fsck.k9.Preferences; -import com.fsck.k9.ServerSettingsSerializer; -import com.fsck.k9.mail.AuthType; -import com.fsck.k9.mail.ConnectionSecurity; -import com.fsck.k9.mail.ServerSettings; -import com.fsck.k9.mailstore.SpecialLocalFoldersCreator; -import com.fsck.k9.preferences.Settings.InvalidSettingValueException; -import kotlinx.datetime.Clock; -import timber.log.Timber; - -import static java.util.Collections.emptyMap; -import static java.util.Collections.unmodifiableMap; - - -public class SettingsImporter { - /** - * Parses an import {@link InputStream} and returns information on whether it contains global - * settings and/or account settings. For all account configurations found, the name of the - * account along with the account UUID is returned. - * - * @param inputStream - * An {@code InputStream} to read the settings from. - * - * @return An {@link ImportContents} instance containing information about the contents of the - * settings file. - * - * @throws SettingsImportExportException - * In case of an error. - */ - public static ImportContents getImportStreamContents(InputStream inputStream) - throws SettingsImportExportException { - - try { - // Parse the import stream but don't save individual settings (overview=true) - SettingsFileParser settingsFileParser = new SettingsFileParser(); - Imported imported = settingsFileParser.parseSettings(inputStream, false, null, true); - - // If the stream contains global settings the "globalSettings" member will not be null - boolean globalSettings = (imported.globalSettings != null); - - final List accounts = new ArrayList<>(); - // If the stream contains at least one account configuration the "accounts" member - // will not be null. - if (imported.accounts != null) { - for (ImportedAccount account : imported.accounts.values()) { - String accountName = getAccountDisplayName(account); - accounts.add(new AccountDescription(accountName, account.uuid)); - } - } - - //TODO: throw exception if neither global settings nor account settings could be found - - return new ImportContents(globalSettings, accounts); - - } catch (SettingsImportExportException e) { - throw e; - } catch (Exception e) { - throw new SettingsImportExportException(e); - } - } - - /** - * Reads an import {@link InputStream} and imports the global settings and/or account - * configurations specified by the arguments. - * - * @param context - * A {@link Context} instance. - * @param inputStream - * The {@code InputStream} to read the settings from. - * @param globalSettings - * {@code true} if global settings should be imported from the file. - * @param accountUuids - * A list of UUIDs of the accounts that should be imported. - * @param overwrite - * {@code true} if existing accounts should be overwritten when an account with the - * same UUID is found in the settings file.
- * Note: This can have side-effects we currently don't handle, e.g. - * changing the account type from IMAP to POP3. So don't use this for now! - * @return An {@link ImportResults} instance containing information about errors and - * successfully imported accounts. - * - * @throws SettingsImportExportException - * In case of an error. - */ - public static ImportResults importSettings(Context context, InputStream inputStream, boolean globalSettings, - List accountUuids, boolean overwrite) throws SettingsImportExportException { - - try { - boolean globalSettingsImported = false; - List importedAccounts = new ArrayList<>(); - List erroneousAccounts = new ArrayList<>(); - - SettingsFileParser settingsFileParser = new SettingsFileParser(); - Imported imported = settingsFileParser.parseSettings(inputStream, globalSettings, accountUuids, false); - - Preferences preferences = Preferences.getPreferences(); - Storage storage = preferences.getStorage(); - - if (globalSettings) { - try { - StorageEditor editor = preferences.createStorageEditor(); - if (imported.globalSettings != null) { - importGlobalSettings(storage, editor, imported.contentVersion, imported.globalSettings); - } else { - Timber.w("Was asked to import global settings but none found."); - } - if (editor.commit()) { - Timber.v("Committed global settings to the preference storage."); - globalSettingsImported = true; - } else { - Timber.v("Failed to commit global settings to the preference storage"); - } - } catch (Exception e) { - Timber.e(e, "Exception while importing global settings"); - } - } - - if (accountUuids != null && accountUuids.size() > 0) { - if (imported.accounts != null) { - for (String accountUuid : accountUuids) { - if (imported.accounts.containsKey(accountUuid)) { - ImportedAccount account = imported.accounts.get(accountUuid); - try { - StorageEditor editor = preferences.createStorageEditor(); - - AccountDescriptionPair importResult = importAccount(context, editor, - imported.contentVersion, account, overwrite); - - if (editor.commit()) { - Timber.v("Committed settings for account \"%s\" to the settings database.", - importResult.imported.name); - - // Add UUID of the account we just imported to the list of - // account UUIDs - if (!importResult.overwritten) { - editor = preferences.createStorageEditor(); - - String newUuid = importResult.imported.uuid; - String oldAccountUuids = preferences.getStorage().getString("accountUuids", ""); - String newAccountUuids = (oldAccountUuids.length() > 0) ? - oldAccountUuids + "," + newUuid : newUuid; - - putString(editor, "accountUuids", newAccountUuids); - - if (!editor.commit()) { - throw new SettingsImportExportException("Failed to set account UUID list"); - } - } - - // Reload accounts - preferences.loadAccounts(); - - importedAccounts.add(importResult); - } else { - Timber.w("Error while committing settings for account \"%s\" to the settings " + - "database.", importResult.original.name); - - erroneousAccounts.add(importResult.original); - } - } catch (InvalidSettingValueException e) { - String reason = e.getMessage(); - if (TextUtils.isEmpty(reason)) { - reason = "Unknown"; - } - Timber.e(e, "Encountered invalid setting while importing account \"%s\", reason: \"%s\"", - account.name, reason); - - erroneousAccounts.add(new AccountDescription(account.name, account.uuid)); - } catch (Exception e) { - Timber.e(e, "Exception while importing account \"%s\"", account.name); - erroneousAccounts.add(new AccountDescription(account.name, account.uuid)); - } - } else { - Timber.w("Was asked to import account with UUID %s. But this account wasn't found.", - accountUuid); - } - } - - StorageEditor editor = preferences.createStorageEditor(); - - if (!editor.commit()) { - throw new SettingsImportExportException("Failed to set default account"); - } - } else { - Timber.w("Was asked to import at least one account but none found."); - } - } - - preferences.loadAccounts(); - - SpecialLocalFoldersCreator localFoldersCreator = DI.get(SpecialLocalFoldersCreator.class); - - // Create special local folders - for (AccountDescriptionPair importedAccount : importedAccounts) { - String accountUuid = importedAccount.imported.uuid; - Account account = preferences.getAccount(accountUuid); - - localFoldersCreator.createSpecialLocalFolders(account); - } - - DI.get(RealGeneralSettingsManager.class).loadSettings(); - Core.setServicesEnabled(context); - - return new ImportResults(globalSettingsImported, importedAccounts, erroneousAccounts); - - } catch (SettingsImportExportException e) { - throw e; - } catch (Exception e) { - throw new SettingsImportExportException(e); - } - } - - private static void importGlobalSettings(Storage storage, StorageEditor editor, int contentVersion, - ImportedSettings settings) { - - // Validate global settings - Map validatedSettings = GeneralSettingsDescriptions.validate(contentVersion, settings.settings); - - // Upgrade global settings to current content version - if (contentVersion != Settings.VERSION) { - GeneralSettingsDescriptions.upgrade(contentVersion, validatedSettings); - } - - // Convert global settings to the string representation used in preference storage - Map stringSettings = GeneralSettingsDescriptions.convert(validatedSettings); - - // Use current global settings as base and overwrite with validated settings read from the import file. - Map mergedSettings = new HashMap<>(GeneralSettingsDescriptions.getGlobalSettings(storage)); - mergedSettings.putAll(stringSettings); - - for (Map.Entry setting : mergedSettings.entrySet()) { - String key = setting.getKey(); - String value = setting.getValue(); - putString(editor, key, value); - } - } - - private static AccountDescriptionPair importAccount(Context context, StorageEditor editor, int contentVersion, - ImportedAccount account, boolean overwrite) throws InvalidSettingValueException { - - AccountDescription original = new AccountDescription(account.name, account.uuid); - - Preferences prefs = Preferences.getPreferences(); - List accounts = prefs.getAccounts(); - - String uuid = account.uuid; - Account existingAccount = prefs.getAccount(uuid); - boolean mergeImportedAccount = (overwrite && existingAccount != null); - - if (!overwrite && existingAccount != null) { - // An account with this UUID already exists, but we're not allowed to overwrite it. - // So generate a new UUID. - uuid = UUID.randomUUID().toString(); - } - - // Make sure the account name is unique - String accountName = account.name; - if (isAccountNameUsed(accountName, accounts)) { - // Account name is already in use. So generate a new one by appending " (x)", where x is the first - // number >= 1 that results in an unused account name. - for (int i = 1; i <= accounts.size(); i++) { - accountName = account.name + " (" + i + ")"; - if (!isAccountNameUsed(accountName, accounts)) { - break; - } - } - } - - // Write account name - String accountKeyPrefix = uuid + "."; - putString(editor, accountKeyPrefix + AccountPreferenceSerializer.ACCOUNT_DESCRIPTION_KEY, accountName); - - if (account.incoming == null) { - // We don't import accounts without incoming server settings - throw new InvalidSettingValueException("Missing incoming server settings"); - } - - // Write incoming server settings - ServerSettings incoming = createServerSettings(account.incoming); - ServerSettingsSerializer serverSettingsSerializer = DI.get(ServerSettingsSerializer.class); - String incomingServer = serverSettingsSerializer.serialize(incoming); - putString(editor, accountKeyPrefix + AccountPreferenceSerializer.INCOMING_SERVER_SETTINGS_KEY, incomingServer); - - String incomingServerName = incoming.host; - boolean incomingPasswordNeeded = AuthType.EXTERNAL != incoming.authenticationType && - AuthType.XOAUTH2 != incoming.authenticationType && - (incoming.password == null || incoming.password.isEmpty()); - - boolean authorizationNeeded = incoming.authenticationType == AuthType.XOAUTH2; - - if (account.outgoing == null) { - throw new InvalidSettingValueException("Missing outgoing server settings"); - } - - String outgoingServerName = null; - boolean outgoingPasswordNeeded = false; - // Write outgoing server settings - ServerSettings outgoing = createServerSettings(account.outgoing); - String outgoingServer = serverSettingsSerializer.serialize(outgoing); - putString(editor, accountKeyPrefix + AccountPreferenceSerializer.OUTGOING_SERVER_SETTINGS_KEY, outgoingServer); - - /* - * Mark account as disabled if the settings file contained a username but no password, except when the - * AuthType is EXTERNAL. - */ - outgoingPasswordNeeded = AuthType.EXTERNAL != outgoing.authenticationType && - AuthType.XOAUTH2 != outgoing.authenticationType && - outgoing.username != null && - !outgoing.username.isEmpty() && - (outgoing.password == null || outgoing.password.isEmpty()); - - authorizationNeeded |= outgoing.authenticationType == AuthType.XOAUTH2; - - outgoingServerName = outgoing.host; - - boolean createAccountDisabled = incomingPasswordNeeded || outgoingPasswordNeeded || authorizationNeeded; - if (createAccountDisabled) { - editor.putBoolean(accountKeyPrefix + "enabled", false); - } - - // Validate account settings - Map validatedSettings = - AccountSettingsDescriptions.validate(contentVersion, account.settings.settings, !mergeImportedAccount); - - // Upgrade account settings to current content version - if (contentVersion != Settings.VERSION) { - AccountSettingsDescriptions.upgrade(contentVersion, validatedSettings); - } - - // Convert account settings to the string representation used in preference storage - Map stringSettings = AccountSettingsDescriptions.convert(validatedSettings); - - // Merge account settings if necessary - Map writeSettings; - if (mergeImportedAccount) { - writeSettings = new HashMap<>(AccountSettingsDescriptions.getAccountSettings(prefs.getStorage(), uuid)); - writeSettings.putAll(stringSettings); - } else { - writeSettings = stringSettings; - } - - // Write account settings - for (Map.Entry setting : writeSettings.entrySet()) { - String key = accountKeyPrefix + setting.getKey(); - String value = setting.getValue(); - putString(editor, key, value); - } - - // If it's a new account generate and write a new "accountNumber" - if (!mergeImportedAccount) { - int newAccountNumber = prefs.generateAccountNumber(); - putString(editor, accountKeyPrefix + "accountNumber", Integer.toString(newAccountNumber)); - } - - // Write identities - if (account.identities != null) { - importIdentities(editor, contentVersion, uuid, account, overwrite, existingAccount, prefs); - } else if (!mergeImportedAccount) { - // Require accounts to at least have one identity - throw new InvalidSettingValueException("Missing identities, there should be at least one."); - } - - // Write folder settings - if (account.folders != null) { - for (ImportedFolder folder : account.folders) { - importFolder(editor, contentVersion, uuid, folder, mergeImportedAccount, prefs); - } - } - - // When deleting an account and then restoring it using settings import, the same account UUID will be used. - // To avoid reusing a previously existing notification channel ID, we need to make sure to use a unique value - // for `messagesNotificationChannelVersion`. - Clock clock = DI.get(Clock.class); - String messageNotificationChannelVersion = Long.toString(clock.now().getEpochSeconds()); - putString(editor, accountKeyPrefix + "messagesNotificationChannelVersion", messageNotificationChannelVersion); - - AccountDescription imported = new AccountDescription(accountName, uuid); - return new AccountDescriptionPair(original, imported, mergeImportedAccount, authorizationNeeded, - incomingPasswordNeeded, outgoingPasswordNeeded, incomingServerName, outgoingServerName); - } - - private static void importFolder(StorageEditor editor, int contentVersion, String uuid, ImportedFolder folder, - boolean overwrite, Preferences prefs) { - - // Validate folder settings - Map validatedSettings = - FolderSettingsDescriptions.validate(contentVersion, folder.settings.settings, !overwrite); - - // Upgrade folder settings to current content version - if (contentVersion != Settings.VERSION) { - FolderSettingsDescriptions.upgrade(contentVersion, validatedSettings); - } - - // Convert folder settings to the string representation used in preference storage - Map stringSettings = FolderSettingsDescriptions.convert(validatedSettings); - - // Merge folder settings if necessary - Map writeSettings; - if (overwrite) { - writeSettings = FolderSettingsDescriptions.getFolderSettings(prefs.getStorage(), uuid, folder.name); - writeSettings.putAll(stringSettings); - } else { - writeSettings = stringSettings; - } - - // Write folder settings - String prefix = uuid + "." + folder.name + "."; - for (Map.Entry setting : writeSettings.entrySet()) { - String key = prefix + setting.getKey(); - String value = setting.getValue(); - putString(editor, key, value); - } - } - - private static void importIdentities(StorageEditor editor, int contentVersion, String uuid, ImportedAccount account, - boolean overwrite, Account existingAccount, Preferences prefs) throws InvalidSettingValueException { - - String accountKeyPrefix = uuid + "."; - - // Gather information about existing identities for this account (if any) - int nextIdentityIndex = 0; - final List existingIdentities; - if (overwrite && existingAccount != null) { - existingIdentities = existingAccount.getIdentities(); - nextIdentityIndex = existingIdentities.size(); - } else { - existingIdentities = new ArrayList<>(); - } - - // Write identities - for (ImportedIdentity identity : account.identities) { - int writeIdentityIndex = nextIdentityIndex; - boolean mergeSettings = false; - if (overwrite && existingIdentities.size() > 0) { - int identityIndex = findIdentity(identity, existingIdentities); - if (identityIndex != -1) { - writeIdentityIndex = identityIndex; - mergeSettings = true; - } - } - if (!mergeSettings) { - nextIdentityIndex++; - } - - String identitySuffix = "." + writeIdentityIndex; - - // Write name used in identity - String identityName = (identity.name == null) ? "" : identity.name; - putString(editor, accountKeyPrefix + AccountPreferenceSerializer.IDENTITY_NAME_KEY + identitySuffix, identityName); - - // Validate email address - if (!IdentitySettingsDescriptions.isEmailAddressValid(identity.email)) { - throw new InvalidSettingValueException("Invalid email address: " + identity.email); - } - - // Write email address - putString(editor, accountKeyPrefix + AccountPreferenceSerializer.IDENTITY_EMAIL_KEY + identitySuffix, identity.email); - - // Write identity description - if (identity.description != null) { - putString(editor, accountKeyPrefix + AccountPreferenceSerializer.IDENTITY_DESCRIPTION_KEY + identitySuffix, - identity.description); - } - - if (identity.settings != null) { - // Validate identity settings - Map validatedSettings = IdentitySettingsDescriptions.validate( - contentVersion, identity.settings.settings, !mergeSettings); - - // Upgrade identity settings to current content version - if (contentVersion != Settings.VERSION) { - IdentitySettingsDescriptions.upgrade(contentVersion, validatedSettings); - } - - // Convert identity settings to the representation used in preference storage - Map stringSettings = IdentitySettingsDescriptions.convert(validatedSettings); - - // Merge identity settings if necessary - Map writeSettings; - if (mergeSettings) { - writeSettings = new HashMap<>(IdentitySettingsDescriptions.getIdentitySettings( - prefs.getStorage(), uuid, writeIdentityIndex)); - writeSettings.putAll(stringSettings); - } else { - writeSettings = stringSettings; - } - - // Write identity settings - for (Map.Entry setting : writeSettings.entrySet()) { - String key = accountKeyPrefix + setting.getKey() + identitySuffix; - String value = setting.getValue(); - putString(editor, key, value); - } - } - } - } - - private static boolean isAccountNameUsed(String name, List accounts) { - for (Account account : accounts) { - if (account == null) { - continue; - } - - if (account.getDisplayName().equals(name)) { - return true; - } - } - return false; - } - - private static int findIdentity(ImportedIdentity identity, List identities) { - for (int i = 0; i < identities.size(); i++) { - Identity existingIdentity = identities.get(i); - if (existingIdentity.getName().equals(identity.name) && - existingIdentity.getEmail().equals(identity.email)) { - return i; - } - } - return -1; - } - - /** - * Write to an {@link SharedPreferences.Editor} while logging what is written if debug logging - * is enabled. - * - * @param editor - * The {@code Editor} to write to. - * @param key - * The name of the preference to modify. - * @param value - * The new value for the preference. - */ - private static void putString(StorageEditor editor, String key, String value) { - if (K9.isDebugLoggingEnabled()) { - String outputValue = value; - if (!K9.isSensitiveDebugLoggingEnabled() && - (key.endsWith("." + AccountPreferenceSerializer.OUTGOING_SERVER_SETTINGS_KEY) || - key.endsWith("." + AccountPreferenceSerializer.INCOMING_SERVER_SETTINGS_KEY))) { - outputValue = "*sensitive*"; - } - Timber.v("Setting %s=%s", key, outputValue); - } - editor.putString(key, value); - } - - private static String getAccountDisplayName(ImportedAccount account) { - String name = account.name; - if (TextUtils.isEmpty(name) && account.identities != null && account.identities.size() > 0) { - name = account.identities.get(0).email; - } - return name; - } - - private static ServerSettings createServerSettings(ImportedServer importedServer) { - String type = ServerTypeConverter.toServerSettingsType(importedServer.type); - int port = convertPort(importedServer.port); - ConnectionSecurity connectionSecurity = convertConnectionSecurity(importedServer.connectionSecurity); - String password = importedServer.authenticationType == AuthType.XOAUTH2 ? "" : importedServer.password; - Map extra = importedServer.extras != null ? - unmodifiableMap(importedServer.extras.settings) : emptyMap(); - - return new ServerSettings(type, importedServer.host, port, connectionSecurity, - importedServer.authenticationType, importedServer.username, password, - importedServer.clientCertificateAlias, extra); - } - - private static int convertPort(String port) { - try { - return Integer.parseInt(port); - } catch (NumberFormatException e) { - return -1; - } - } - - private static ConnectionSecurity convertConnectionSecurity(String connectionSecurity) { - try { - /* - * TODO: - * Add proper settings validation and upgrade capability for server settings. - * Once that exists, move this code into a SettingsUpgrader. - */ - if ("SSL_TLS_OPTIONAL".equals(connectionSecurity)) { - return ConnectionSecurity.SSL_TLS_REQUIRED; - } else if ("STARTTLS_OPTIONAL".equals(connectionSecurity)) { - return ConnectionSecurity.STARTTLS_REQUIRED; - } - return ConnectionSecurity.valueOf(connectionSecurity); - } catch (Exception e) { - return ConnectionSecurity.SSL_TLS_REQUIRED; - } - } -} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/SettingsImporter.kt b/app/core/src/main/java/com/fsck/k9/preferences/SettingsImporter.kt new file mode 100644 index 000000000..71a3cf1b9 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/preferences/SettingsImporter.kt @@ -0,0 +1,554 @@ +package com.fsck.k9.preferences + +import android.content.Context +import com.fsck.k9.Account +import com.fsck.k9.AccountPreferenceSerializer +import com.fsck.k9.Core +import com.fsck.k9.K9 +import com.fsck.k9.Preferences +import com.fsck.k9.ServerSettingsSerializer +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mailstore.SpecialLocalFoldersCreator +import com.fsck.k9.preferences.ServerTypeConverter.toServerSettingsType +import com.fsck.k9.preferences.Settings.InvalidSettingValueException +import java.io.InputStream +import java.util.UUID +import kotlinx.datetime.Clock +import timber.log.Timber + +// TODO: Further refactor this class to be able to get rid of these detekt issues. +@Suppress( + "LongMethod", + "CyclomaticComplexMethod", + "NestedBlockDepth", + "TooManyFunctions", + "TooGenericExceptionCaught", + "SwallowedException", + "ReturnCount", + "ThrowsCount", +) +class SettingsImporter internal constructor( + private val settingsFileParser: SettingsFileParser, + private val preferences: Preferences, + private val generalSettingsManager: RealGeneralSettingsManager, + private val localFoldersCreator: SpecialLocalFoldersCreator, + private val serverSettingsSerializer: ServerSettingsSerializer, + private val clock: Clock, + private val context: Context, +) { + /** + * Parses an import [InputStream] and returns information on whether it contains global settings and/or account + * settings. For all account configurations found, the name of the account along with the account UUID is returned. + * + * @param inputStream An `InputStream` to read the settings from. + * + * @return An [ImportContents] instance containing information about the contents of the settings file. + * + * @throws SettingsImportExportException In case of an error. + */ + @Throws(SettingsImportExportException::class) + fun getImportStreamContents(inputStream: InputStream): ImportContents { + try { + // Parse the import stream but don't save individual settings (overview=true) + val imported = settingsFileParser.parseSettings( + inputStream = inputStream, + globalSettings = false, + accountUuids = null, + overview = true, + ) + + // If the stream contains global settings the "globalSettings" member will not be null + val globalSettings = (imported.globalSettings != null) + + val accounts = imported.accounts?.values.orEmpty().map { importedAccount -> + AccountDescription( + name = getAccountDisplayName(importedAccount), + uuid = importedAccount.uuid, + ) + } + + // TODO: throw exception if neither global settings nor account settings could be found + return ImportContents(globalSettings, accounts) + } catch (e: SettingsImportExportException) { + throw e + } catch (e: Exception) { + throw SettingsImportExportException(e) + } + } + + /** + * Reads an import [InputStream] and imports the global settings and/or account configurations specified by the + * arguments. + * + * @param inputStream The `InputStream` to read the settings from. + * @param globalSettings `true` if global settings should be imported from the file. + * @param accountUuids A list of UUIDs of the accounts that should be imported. + * + * @return An [ImportResults] instance containing information about errors and successfully imported accounts. + * + * @throws SettingsImportExportException In case of an error. + */ + @Throws(SettingsImportExportException::class) + fun importSettings( + inputStream: InputStream, + globalSettings: Boolean, + accountUuids: List?, + ): ImportResults { + try { + var globalSettingsImported = false + val importedAccounts = mutableListOf() + val erroneousAccounts = mutableListOf() + + val imported = settingsFileParser.parseSettings( + inputStream = inputStream, + globalSettings = globalSettings, + accountUuids = accountUuids, + overview = false, + ) + + val storage = preferences.storage + + if (globalSettings) { + try { + val editor = preferences.createStorageEditor() + if (imported.globalSettings != null) { + importGlobalSettings(storage, editor, imported.contentVersion, imported.globalSettings) + } else { + Timber.w("Was asked to import global settings but none found.") + } + + if (editor.commit()) { + Timber.v("Committed global settings to the preference storage.") + globalSettingsImported = true + } else { + Timber.v("Failed to commit global settings to the preference storage") + } + } catch (e: Exception) { + Timber.e(e, "Exception while importing global settings") + } + } + + if (!accountUuids.isNullOrEmpty()) { + if (imported.accounts != null) { + for (accountUuid in accountUuids) { + val account = imported.accounts[accountUuid] + if (account != null) { + try { + var editor = preferences.createStorageEditor() + + val importResult = importAccount(editor, imported.contentVersion, account) + + if (editor.commit()) { + Timber.v( + "Committed settings for account \"%s\" to the settings database.", + importResult.imported.name, + ) + + // Add UUID of the account we just imported to the list of account UUIDs + editor = preferences.createStorageEditor() + + val newUuid = importResult.imported.uuid + val oldAccountUuids = preferences.storage.getString("accountUuids", "") + val newAccountUuids = if (oldAccountUuids.isNotEmpty()) { + "$oldAccountUuids,$newUuid" + } else { + newUuid + } + + putString(editor, "accountUuids", newAccountUuids) + + if (!editor.commit()) { + throw SettingsImportExportException("Failed to set account UUID list") + } + + // Reload accounts + preferences.loadAccounts() + + importedAccounts.add(importResult) + } else { + Timber.w( + "Error while committing settings for account \"%s\" to the settings database.", + importResult.original.name, + ) + + erroneousAccounts.add(importResult.original) + } + } catch (e: InvalidSettingValueException) { + Timber.e(e, "Encountered invalid setting while importing account \"%s\"", account.name) + + erroneousAccounts.add(AccountDescription(account.name!!, account.uuid)) + } catch (e: Exception) { + Timber.e(e, "Exception while importing account \"%s\"", account.name) + + erroneousAccounts.add(AccountDescription(account.name!!, account.uuid)) + } + } else { + Timber.w( + "Was asked to import account with UUID %s. But this account wasn't found.", + accountUuid, + ) + } + } + + val editor = preferences.createStorageEditor() + + if (!editor.commit()) { + throw SettingsImportExportException("Failed to set default account") + } + } else { + Timber.w("Was asked to import at least one account but none found.") + } + } + + preferences.loadAccounts() + + // Create special local folders + for (importedAccount in importedAccounts) { + val accountUuid = importedAccount.imported.uuid + val account = preferences.getAccount(accountUuid) ?: error("Failed to load account: $accountUuid") + + localFoldersCreator.createSpecialLocalFolders(account) + } + + generalSettingsManager.loadSettings() + Core.setServicesEnabled(context) + + return ImportResults(globalSettingsImported, importedAccounts, erroneousAccounts) + } catch (e: SettingsImportExportException) { + throw e + } catch (e: Exception) { + throw SettingsImportExportException(e) + } + } + + private fun importGlobalSettings( + storage: Storage, + editor: StorageEditor, + contentVersion: Int, + settings: ImportedSettings, + ) { + // Validate global settings + val validatedSettings = GeneralSettingsDescriptions.validate(contentVersion, settings.settings) + + // Upgrade global settings to current content version + if (contentVersion != Settings.VERSION) { + GeneralSettingsDescriptions.upgrade(contentVersion, validatedSettings) + } + + // Convert global settings to the string representation used in preference storage + val stringSettings = GeneralSettingsDescriptions.convert(validatedSettings) + + // Use current global settings as base and overwrite with validated settings read from the import file. + val mergedSettings = GeneralSettingsDescriptions.getGlobalSettings(storage).toMutableMap() + mergedSettings.putAll(stringSettings) + + for ((key, value) in mergedSettings) { + putString(editor, key, value) + } + } + + @Throws(InvalidSettingValueException::class) + private fun importAccount( + editor: StorageEditor, + contentVersion: Int, + account: ImportedAccount, + ): AccountDescriptionPair { + val original = AccountDescription(account.name!!, account.uuid) + + val prefs = Preferences.getPreferences() + val accounts = prefs.getAccounts() + + val existingAccount = prefs.getAccount(account.uuid) + val uuid = if (existingAccount != null) { + // An account with this UUID already exists. So generate a new UUID. + UUID.randomUUID().toString() + } else { + account.uuid + } + + // Make sure the account name is unique + var accountName = account.name + if (isAccountNameUsed(accountName, accounts)) { + // Account name is already in use. So generate a new one by appending " (x)", where x is the first + // number >= 1 that results in an unused account name. + for (i in 1..accounts.size) { + accountName = account.name + " (" + i + ")" + if (!isAccountNameUsed(accountName, accounts)) { + break + } + } + } + + // Write account name + val accountKeyPrefix = "$uuid." + putString(editor, accountKeyPrefix + AccountPreferenceSerializer.ACCOUNT_DESCRIPTION_KEY, accountName) + + if (account.incoming == null) { + // We don't import accounts without incoming server settings + throw InvalidSettingValueException("Missing incoming server settings") + } + + // Write incoming server settings + val incoming = createServerSettings(account.incoming) + val incomingServer = serverSettingsSerializer.serialize(incoming) + putString(editor, accountKeyPrefix + AccountPreferenceSerializer.INCOMING_SERVER_SETTINGS_KEY, incomingServer) + + val incomingServerName = incoming.host + val incomingPasswordNeeded = + incoming.authenticationType != AuthType.EXTERNAL && incoming.authenticationType != AuthType.XOAUTH2 && + incoming.password.isNullOrEmpty() + + var authorizationNeeded = incoming.authenticationType == AuthType.XOAUTH2 + + if (account.outgoing == null) { + throw InvalidSettingValueException("Missing outgoing server settings") + } + + // Write outgoing server settings + val outgoing = createServerSettings(account.outgoing) + val outgoingServer = serverSettingsSerializer.serialize(outgoing) + putString(editor, accountKeyPrefix + AccountPreferenceSerializer.OUTGOING_SERVER_SETTINGS_KEY, outgoingServer) + + /* + * Mark account as disabled if the settings file contained a username but no password, except when the + * AuthType is EXTERNAL. + */ + val outgoingPasswordNeeded = + outgoing.authenticationType != AuthType.EXTERNAL && outgoing.authenticationType != AuthType.XOAUTH2 && + outgoing.username.isNotEmpty() && outgoing.password.isNullOrEmpty() + + authorizationNeeded = authorizationNeeded || outgoing.authenticationType == AuthType.XOAUTH2 + + val outgoingServerName = outgoing.host + + val createAccountDisabled = incomingPasswordNeeded || outgoingPasswordNeeded || authorizationNeeded + if (createAccountDisabled) { + editor.putBoolean(accountKeyPrefix + "enabled", false) + } + + // Validate account settings + val validatedSettings = + AccountSettingsDescriptions.validate(contentVersion, account.settings!!.settings, true) + + // Upgrade account settings to current content version + if (contentVersion != Settings.VERSION) { + AccountSettingsDescriptions.upgrade(contentVersion, validatedSettings) + } + + // Convert account settings to the string representation used in preference storage + val stringSettings = AccountSettingsDescriptions.convert(validatedSettings) + + // Write account settings + for ((accountKey, value) in stringSettings) { + val key = accountKeyPrefix + accountKey + putString(editor, key, value) + } + + // Generate and write a new "accountNumber" + val newAccountNumber = prefs.generateAccountNumber() + putString(editor, accountKeyPrefix + "accountNumber", newAccountNumber.toString()) + + // Write identities + if (account.identities != null) { + importIdentities(editor, contentVersion, uuid, account) + } else { + // Require accounts to at least have one identity + throw InvalidSettingValueException("Missing identities, there should be at least one.") + } + + // Write folder settings + if (account.folders != null) { + for (folder in account.folders) { + importFolder(editor, contentVersion, uuid, folder) + } + } + + // When deleting an account and then restoring it using settings import, the same account UUID will be used. + // To avoid reusing a previously existing notification channel ID, we need to make sure to use a unique value + // for `messagesNotificationChannelVersion`. + val messageNotificationChannelVersion = clock.now().epochSeconds.toString() + putString(editor, accountKeyPrefix + "messagesNotificationChannelVersion", messageNotificationChannelVersion) + + val imported = AccountDescription(accountName!!, uuid) + return AccountDescriptionPair( + original, + imported, + authorizationNeeded, + incomingPasswordNeeded, + outgoingPasswordNeeded, + incomingServerName!!, + outgoingServerName!!, + ) + } + + private fun importFolder( + editor: StorageEditor, + contentVersion: Int, + uuid: String, + folder: ImportedFolder, + ) { + // Validate folder settings + val validatedSettings = + FolderSettingsDescriptions.validate(contentVersion, folder.settings!!.settings, true) + + // Upgrade folder settings to current content version + if (contentVersion != Settings.VERSION) { + FolderSettingsDescriptions.upgrade(contentVersion, validatedSettings) + } + + // Convert folder settings to the string representation used in preference storage + val stringSettings = FolderSettingsDescriptions.convert(validatedSettings) + + // Write folder settings + val prefix = uuid + "." + folder.name + "." + for ((folderKey, value) in stringSettings) { + val key = prefix + folderKey + putString(editor, key, value) + } + } + + @Throws(InvalidSettingValueException::class) + private fun importIdentities( + editor: StorageEditor, + contentVersion: Int, + uuid: String, + account: ImportedAccount, + ) { + val accountKeyPrefix = "$uuid." + + // Gather information about existing identities for this account (if any) + + // Write identities + for ((index, identity) in account.identities!!.withIndex()) { + val identitySuffix = ".$index" + + // Write name used in identity + val identityName = identity.name.orEmpty() + putString( + editor, + accountKeyPrefix + AccountPreferenceSerializer.IDENTITY_NAME_KEY + identitySuffix, + identityName, + ) + + // Validate email address + if (!IdentitySettingsDescriptions.isEmailAddressValid(identity.email)) { + throw InvalidSettingValueException("Invalid email address: " + identity.email) + } + + // Write email address + putString( + editor, + accountKeyPrefix + AccountPreferenceSerializer.IDENTITY_EMAIL_KEY + identitySuffix, + identity.email, + ) + + // Write identity description + if (identity.description != null) { + putString( + editor, + accountKeyPrefix + AccountPreferenceSerializer.IDENTITY_DESCRIPTION_KEY + identitySuffix, + identity.description, + ) + } + + if (identity.settings != null) { + // Validate identity settings + val validatedSettings = IdentitySettingsDescriptions.validate( + contentVersion, + identity.settings.settings, + true, + ) + + // Upgrade identity settings to current content version + if (contentVersion != Settings.VERSION) { + IdentitySettingsDescriptions.upgrade(contentVersion, validatedSettings) + } + + // Convert identity settings to the representation used in preference storage + val stringSettings = IdentitySettingsDescriptions.convert(validatedSettings) + + // Write identity settings + for ((identityKey, value) in stringSettings) { + val key = accountKeyPrefix + identityKey + identitySuffix + putString(editor, key, value) + } + } + } + } + + private fun isAccountNameUsed(name: String?, accounts: List): Boolean { + return accounts.any { it.displayName == name } + } + + /** + * Write to a [StorageEditor] while logging what is written if debug logging is enabled. + * + * @param editor The `Editor` to write to. + * @param key The name of the preference to modify. + * @param value The new value for the preference. + */ + private fun putString(editor: StorageEditor, key: String, value: String?) { + if (K9.isDebugLoggingEnabled) { + var outputValue = value + if (!K9.isSensitiveDebugLoggingEnabled && + ( + key.endsWith("." + AccountPreferenceSerializer.OUTGOING_SERVER_SETTINGS_KEY) || + key.endsWith("." + AccountPreferenceSerializer.INCOMING_SERVER_SETTINGS_KEY) + ) + ) { + outputValue = "*sensitive*" + } + + Timber.v("Setting %s=%s", key, outputValue) + } + + editor.putString(key, value) + } + + private fun getAccountDisplayName(account: ImportedAccount): String { + return account.name?.takeIf { it.isNotEmpty() } + ?: account.identities?.firstOrNull()?.email + ?: error("Account name missing") + } + + private fun createServerSettings(importedServer: ImportedServer): ServerSettings { + val type = toServerSettingsType(importedServer.type!!) + val port = convertPort(importedServer.port) + val connectionSecurity = convertConnectionSecurity(importedServer.connectionSecurity) + val password = if (importedServer.authenticationType == AuthType.XOAUTH2) "" else importedServer.password + val extra = importedServer.extras?.settings.orEmpty() + + return ServerSettings( + type, + importedServer.host, + port, + connectionSecurity, + importedServer.authenticationType!!, + importedServer.username!!, + password, + importedServer.clientCertificateAlias, + extra, + ) + } + + private fun convertPort(port: String?): Int { + return port?.toIntOrNull() ?: -1 + } + + private fun convertConnectionSecurity(connectionSecurity: String?): ConnectionSecurity { + try { + // TODO: Add proper settings validation and upgrade capability for server settings. Once that exists, move + // this code into a SettingsUpgrader. + if ("SSL_TLS_OPTIONAL" == connectionSecurity) { + return ConnectionSecurity.SSL_TLS_REQUIRED + } else if ("STARTTLS_OPTIONAL" == connectionSecurity) { + return ConnectionSecurity.STARTTLS_REQUIRED + } + return ConnectionSecurity.valueOf(connectionSecurity!!) + } catch (e: Exception) { + return ConnectionSecurity.SSL_TLS_REQUIRED + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/preferences/SettingsImporterTest.kt b/app/core/src/test/java/com/fsck/k9/preferences/SettingsImporterTest.kt index 6a6c1a15e..31ed8a7b4 100644 --- a/app/core/src/test/java/com/fsck/k9/preferences/SettingsImporterTest.kt +++ b/app/core/src/test/java/com/fsck/k9/preferences/SettingsImporterTest.kt @@ -14,13 +14,33 @@ import assertk.assertions.isTrue import assertk.assertions.prop import com.fsck.k9.K9RobolectricTest import com.fsck.k9.Preferences +import com.fsck.k9.ServerSettingsSerializer +import com.fsck.k9.mailstore.SpecialLocalFoldersCreator import java.util.UUID +import kotlinx.datetime.Clock import org.junit.Before import org.junit.Test +import org.koin.core.component.inject +import org.koin.test.inject import org.robolectric.RuntimeEnvironment class SettingsImporterTest : K9RobolectricTest() { private val context: Context = RuntimeEnvironment.getApplication() + private val preferences: Preferences by inject() + private val realGeneralSettingsManager: RealGeneralSettingsManager by inject() + private val specialLocalFoldersCreator: SpecialLocalFoldersCreator by inject() + private val serverSettingsSerializer: ServerSettingsSerializer by inject() + private val clock: Clock by inject() + + private val settingsImporter = SettingsImporter( + settingsFileParser = SettingsFileParser(), + preferences = preferences, + generalSettingsManager = realGeneralSettingsManager, + localFoldersCreator = specialLocalFoldersCreator, + serverSettingsSerializer = serverSettingsSerializer, + clock = clock, + context = context, + ) @Before fun before() { @@ -38,7 +58,7 @@ class SettingsImporterTest : K9RobolectricTest() { val accountUuids = emptyList() assertFailure { - SettingsImporter.importSettings(context, inputStream, true, accountUuids, true) + settingsImporter.importSettings(inputStream, globalSettings = true, accountUuids) }.isInstanceOf() } @@ -48,7 +68,7 @@ class SettingsImporterTest : K9RobolectricTest() { val accountUuids = emptyList() assertFailure { - SettingsImporter.importSettings(context, inputStream, true, accountUuids, true) + settingsImporter.importSettings(inputStream, globalSettings = true, accountUuids) }.isInstanceOf() } @@ -58,7 +78,7 @@ class SettingsImporterTest : K9RobolectricTest() { val accountUuids = emptyList() assertFailure { - SettingsImporter.importSettings(context, inputStream, true, accountUuids, true) + settingsImporter.importSettings(inputStream, globalSettings = true, accountUuids) }.isInstanceOf() } @@ -68,7 +88,7 @@ class SettingsImporterTest : K9RobolectricTest() { val accountUuids = emptyList() assertFailure { - SettingsImporter.importSettings(context, inputStream, true, accountUuids, true) + settingsImporter.importSettings(inputStream, globalSettings = true, accountUuids) }.isInstanceOf() } @@ -78,7 +98,7 @@ class SettingsImporterTest : K9RobolectricTest() { val accountUuids = emptyList() assertFailure { - SettingsImporter.importSettings(context, inputStream, true, accountUuids, true) + settingsImporter.importSettings(inputStream, globalSettings = true, accountUuids) }.isInstanceOf() } @@ -88,7 +108,7 @@ class SettingsImporterTest : K9RobolectricTest() { val accountUuids = emptyList() assertFailure { - SettingsImporter.importSettings(context, inputStream, true, accountUuids, true) + settingsImporter.importSettings(inputStream, globalSettings = true, accountUuids) }.isInstanceOf() } @@ -98,7 +118,7 @@ class SettingsImporterTest : K9RobolectricTest() { val accountUuids = emptyList() assertFailure { - SettingsImporter.importSettings(context, inputStream, true, accountUuids, true) + settingsImporter.importSettings(inputStream, globalSettings = true, accountUuids) }.isInstanceOf() } @@ -137,7 +157,7 @@ class SettingsImporterTest : K9RobolectricTest() { """.trimIndent().byteInputStream() val accountUuids = listOf(accountUuid) - val results = SettingsImporter.importSettings(context, inputStream, true, accountUuids, false) + val results = settingsImporter.importSettings(inputStream, globalSettings = true, accountUuids) assertThat(results).all { prop(ImportResults::erroneousAccounts).isEmpty() @@ -174,7 +194,7 @@ class SettingsImporterTest : K9RobolectricTest() { """.trimIndent().byteInputStream() - val results = SettingsImporter.getImportStreamContents(inputStream) + val results = settingsImporter.getImportStreamContents(inputStream) assertThat(results).all { prop(ImportContents::globalSettings).isFalse() @@ -207,7 +227,7 @@ class SettingsImporterTest : K9RobolectricTest() { """.trimIndent().byteInputStream() - val results = SettingsImporter.getImportStreamContents(inputStream) + val results = settingsImporter.getImportStreamContents(inputStream) assertThat(results).all { prop(ImportContents::globalSettings).isFalse() 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 1611b9e3f..70559e280 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 @@ -6,7 +6,13 @@ import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module val featureSettingsImportModule = module { - viewModel { SettingsImportViewModel(context = get(), accountActivator = get()) } + viewModel { + SettingsImportViewModel( + context = get(), + settingsImporter = get(), + accountActivator = get(), + ) + } viewModel { AuthViewModel( 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 aafe84e93..b9d0cc421 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 @@ -30,6 +30,7 @@ private typealias AccountNumber = Int internal class SettingsImportViewModel( private val context: Context, + private val settingsImporter: SettingsImporter, private val accountActivator: AccountActivator, ) : ViewModel() { private val uiModelLiveData = MutableLiveData() @@ -382,14 +383,20 @@ internal class SettingsImportViewModel( } private fun readSettings(contentUri: Uri): ImportContents { - return context.contentResolver.openInputStream(contentUri).use { inputStream -> - SettingsImporter.getImportStreamContents(inputStream) + val inputStream = context.contentResolver.openInputStream(contentUri) + ?: error("Failed to open settings file for reading: $contentUri") + + return inputStream.use { + settingsImporter.getImportStreamContents(inputStream) } } private fun importSettings(contentUri: Uri, generalSettings: Boolean, accounts: List): ImportResults { - return context.contentResolver.openInputStream(contentUri).use { inputStream -> - SettingsImporter.importSettings(context, inputStream, generalSettings, accounts, false) + val inputStream = context.contentResolver.openInputStream(contentUri) + ?: error("Failed to open settings file for reading: $contentUri") + + return inputStream.use { + settingsImporter.importSettings(inputStream, generalSettings, accounts) } }