Merge pull request #7766 from thunderbird/settings_import_refactor2

Refactor `SettingsImporter` [2/x]
This commit is contained in:
cketti 2024-04-23 17:06:20 +02:00 committed by GitHub
commit 041330ae1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 653 additions and 666 deletions

View file

@ -1,23 +1,22 @@
package com.fsck.k9.preferences
data class ImportResults(
@JvmField val globalSettings: Boolean,
@JvmField val importedAccounts: List<AccountDescriptionPair>,
@JvmField val erroneousAccounts: List<AccountDescription>,
val globalSettings: Boolean,
val importedAccounts: List<AccountDescriptionPair>,
val erroneousAccounts: List<AccountDescription>,
)
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,
)

View file

@ -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(),
)
}
}

View file

@ -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<String, ImportedAccount>?,
val contentVersion: Int,
val globalSettings: ImportedSettings?,
val accounts: Map<String, ImportedAccount>?,
)
internal data class ImportedSettings(
@JvmField val settings: Map<String, String> = emptyMap(),
val settings: Map<String, String> = 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<ImportedIdentity>?,
@JvmField val folders: List<ImportedFolder>?,
val uuid: String,
val name: String?,
val incoming: ImportedServer?,
val outgoing: ImportedServer?,
val settings: ImportedSettings?,
val identities: List<ImportedIdentity>?,
val folders: List<ImportedFolder>?,
)
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?,
)

View file

@ -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<AccountDescription> 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.<br>
* <strong>Note:</strong> 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<String> accountUuids, boolean overwrite) throws SettingsImportExportException {
try {
boolean globalSettingsImported = false;
List<AccountDescriptionPair> importedAccounts = new ArrayList<>();
List<AccountDescription> 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<String, Object> 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<String, String> stringSettings = GeneralSettingsDescriptions.convert(validatedSettings);
// Use current global settings as base and overwrite with validated settings read from the import file.
Map<String, String> mergedSettings = new HashMap<>(GeneralSettingsDescriptions.getGlobalSettings(storage));
mergedSettings.putAll(stringSettings);
for (Map.Entry<String, String> 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<Account> 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<String, Object> 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<String, String> stringSettings = AccountSettingsDescriptions.convert(validatedSettings);
// Merge account settings if necessary
Map<String, String> writeSettings;
if (mergeImportedAccount) {
writeSettings = new HashMap<>(AccountSettingsDescriptions.getAccountSettings(prefs.getStorage(), uuid));
writeSettings.putAll(stringSettings);
} else {
writeSettings = stringSettings;
}
// Write account settings
for (Map.Entry<String, String> 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<String, Object> 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<String, String> stringSettings = FolderSettingsDescriptions.convert(validatedSettings);
// Merge folder settings if necessary
Map<String, String> 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<String, String> 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<Identity> 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<String, Object> 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<String, String> stringSettings = IdentitySettingsDescriptions.convert(validatedSettings);
// Merge identity settings if necessary
Map<String, String> writeSettings;
if (mergeSettings) {
writeSettings = new HashMap<>(IdentitySettingsDescriptions.getIdentitySettings(
prefs.getStorage(), uuid, writeIdentityIndex));
writeSettings.putAll(stringSettings);
} else {
writeSettings = stringSettings;
}
// Write identity settings
for (Map.Entry<String, String> setting : writeSettings.entrySet()) {
String key = accountKeyPrefix + setting.getKey() + identitySuffix;
String value = setting.getValue();
putString(editor, key, value);
}
}
}
}
private static boolean isAccountNameUsed(String name, List<Account> 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<Identity> 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<String, String> 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;
}
}
}

View file

@ -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<String>?,
): ImportResults {
try {
var globalSettingsImported = false
val importedAccounts = mutableListOf<AccountDescriptionPair>()
val erroneousAccounts = mutableListOf<AccountDescription>()
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<Account>): 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
}
}
}

View file

@ -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<String>()
assertFailure {
SettingsImporter.importSettings(context, inputStream, true, accountUuids, true)
settingsImporter.importSettings(inputStream, globalSettings = true, accountUuids)
}.isInstanceOf<SettingsImportExportException>()
}
@ -48,7 +68,7 @@ class SettingsImporterTest : K9RobolectricTest() {
val accountUuids = emptyList<String>()
assertFailure {
SettingsImporter.importSettings(context, inputStream, true, accountUuids, true)
settingsImporter.importSettings(inputStream, globalSettings = true, accountUuids)
}.isInstanceOf<SettingsImportExportException>()
}
@ -58,7 +78,7 @@ class SettingsImporterTest : K9RobolectricTest() {
val accountUuids = emptyList<String>()
assertFailure {
SettingsImporter.importSettings(context, inputStream, true, accountUuids, true)
settingsImporter.importSettings(inputStream, globalSettings = true, accountUuids)
}.isInstanceOf<SettingsImportExportException>()
}
@ -68,7 +88,7 @@ class SettingsImporterTest : K9RobolectricTest() {
val accountUuids = emptyList<String>()
assertFailure {
SettingsImporter.importSettings(context, inputStream, true, accountUuids, true)
settingsImporter.importSettings(inputStream, globalSettings = true, accountUuids)
}.isInstanceOf<SettingsImportExportException>()
}
@ -78,7 +98,7 @@ class SettingsImporterTest : K9RobolectricTest() {
val accountUuids = emptyList<String>()
assertFailure {
SettingsImporter.importSettings(context, inputStream, true, accountUuids, true)
settingsImporter.importSettings(inputStream, globalSettings = true, accountUuids)
}.isInstanceOf<SettingsImportExportException>()
}
@ -88,7 +108,7 @@ class SettingsImporterTest : K9RobolectricTest() {
val accountUuids = emptyList<String>()
assertFailure {
SettingsImporter.importSettings(context, inputStream, true, accountUuids, true)
settingsImporter.importSettings(inputStream, globalSettings = true, accountUuids)
}.isInstanceOf<SettingsImportExportException>()
}
@ -98,7 +118,7 @@ class SettingsImporterTest : K9RobolectricTest() {
val accountUuids = emptyList<String>()
assertFailure {
SettingsImporter.importSettings(context, inputStream, true, accountUuids, true)
settingsImporter.importSettings(inputStream, globalSettings = true, accountUuids)
}.isInstanceOf<SettingsImportExportException>()
}
@ -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() {
</k9settings>
""".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() {
</k9settings>
""".trimIndent().byteInputStream()
val results = SettingsImporter.getImportStreamContents(inputStream)
val results = settingsImporter.getImportStreamContents(inputStream)
assertThat(results).all {
prop(ImportContents::globalSettings).isFalse()

View file

@ -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(

View file

@ -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<SettingsImportUiModel>()
@ -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<AccountUuid>): 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)
}
}