package com.fsck.k9.preferences; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.SortedMap; import java.util.TreeMap; import timber.log.Timber; import com.fsck.k9.FontSizes; import com.fsck.k9.K9; /* * TODO: * - use the default values defined in GlobalSettings and AccountSettings when creating new * accounts * - think of a better way to validate enums than to use the resource arrays (i.e. get rid of * ResourceArrayValidator); maybe even use the settings description for the settings UI * - add unit test that validates the default values are actually valid according to the validator */ public class Settings { /** * Version number of global and account settings. * * <p> * This value is used as "version" attribute in the export file. It needs to be incremented * when a global or account setting is added or removed, or when the format of a setting * is changed (e.g. add a value to an enum). * </p> * * @see SettingsExporter */ public static final int VERSION = 47; static Map<String, Object> validate(int version, Map<String, TreeMap<Integer, SettingsDescription>> settings, Map<String, String> importedSettings, boolean useDefaultValues) { Map<String, Object> validatedSettings = new HashMap<>(); for (Map.Entry<String, TreeMap<Integer, SettingsDescription>> versionedSetting : settings.entrySet()) { // Get the setting description with the highest version lower than or equal to the // supplied content version. TreeMap<Integer, SettingsDescription> versions = versionedSetting.getValue(); SortedMap<Integer, SettingsDescription> headMap = versions.headMap(version + 1); // Skip this setting if it was introduced after 'version' if (headMap.isEmpty()) { continue; } Integer settingVersion = headMap.lastKey(); SettingsDescription desc = versions.get(settingVersion); // Skip this setting if it is no longer used in 'version' if (desc == null) { continue; } String key = versionedSetting.getKey(); boolean useDefaultValue; if (!importedSettings.containsKey(key)) { Timber.v("Key \"%s\" wasn't found in the imported file.%s", key, (useDefaultValues) ? " Using default value." : ""); useDefaultValue = useDefaultValues; } else { String prettyValue = importedSettings.get(key); try { Object internalValue = desc.fromPrettyString(prettyValue); validatedSettings.put(key, internalValue); useDefaultValue = false; } catch (InvalidSettingValueException e) { Timber.v("Key \"%s\" has invalid value \"%s\" in imported file. %s", key, prettyValue, (useDefaultValues) ? "Using default value." : "Skipping."); useDefaultValue = useDefaultValues; } } if (useDefaultValue) { Object defaultValue = desc.getDefaultValue(); validatedSettings.put(key, defaultValue); } } return validatedSettings; } /** * Upgrade settings using the settings structure and/or special upgrade code. * * @param version * The content version of the settings in {@code validatedSettingsMutable}. * @param customUpgraders * A map of {@link SettingsUpgrader}s for nontrivial settings upgrades. * @param settings * The structure describing the different settings, possibly containing multiple * versions. * @param validatedSettingsMutable * The settings as returned by {@link Settings#validate(int, Map, Map, boolean)}. * This map is modified and contains the upgraded settings when this method returns. * * @return A set of setting names that were removed during the upgrade process or {@code null} * if none were removed. */ public static Set<String> upgrade(int version, Map<Integer, SettingsUpgrader> customUpgraders, Map<String, TreeMap<Integer, SettingsDescription>> settings, Map<String, Object> validatedSettingsMutable) { Set<String> deletedSettings = null; for (int toVersion = version + 1; toVersion <= VERSION; toVersion++) { if (customUpgraders.containsKey(toVersion)) { SettingsUpgrader upgrader = customUpgraders.get(toVersion); deletedSettings = upgrader.upgrade(validatedSettingsMutable); } deletedSettings = upgradeSettingsGeneric(settings, validatedSettingsMutable, deletedSettings, toVersion); } return deletedSettings; } private static Set<String> upgradeSettingsGeneric(Map<String, TreeMap<Integer, SettingsDescription>> settings, Map<String, Object> validatedSettingsMutable, Set<String> deletedSettingsMutable, int toVersion) { for (Entry<String, TreeMap<Integer, SettingsDescription>> versions : settings.entrySet()) { String settingName = versions.getKey(); TreeMap<Integer, SettingsDescription> versionedSettings = versions.getValue(); boolean isNewlyAddedSetting = versionedSettings.firstKey() == toVersion; if (isNewlyAddedSetting) { boolean wasHandledByCustomUpgrader = validatedSettingsMutable.containsKey(settingName); if (wasHandledByCustomUpgrader) { continue; } SettingsDescription setting = versionedSettings.get(toVersion); if (setting == null) { throw new AssertionError("First version of a setting must be non-null!"); } upgradeSettingInsertDefault(validatedSettingsMutable, settingName, setting); } Integer highestVersion = versionedSettings.lastKey(); boolean isRemovedSetting = (highestVersion == toVersion && versionedSettings.get(highestVersion) == null); if (isRemovedSetting) { if (deletedSettingsMutable == null) { deletedSettingsMutable = new HashSet<>(); } upgradeSettingRemove(validatedSettingsMutable, deletedSettingsMutable, settingName); } } return deletedSettingsMutable; } private static <T> void upgradeSettingInsertDefault(Map<String, Object> validatedSettingsMutable, String settingName, SettingsDescription<T> setting) { T defaultValue = setting.getDefaultValue(); validatedSettingsMutable.put(settingName, defaultValue); if (K9.isDebug()) { String prettyValue = setting.toPrettyString(defaultValue); Timber.v("Added new setting \"%s\" with default value \"%s\"", settingName, prettyValue); } } private static void upgradeSettingRemove(Map<String, Object> validatedSettingsMutable, Set<String> deletedSettingsMutable, String settingName) { validatedSettingsMutable.remove(settingName); deletedSettingsMutable.add(settingName); Timber.v("Removed setting \"%s\"", settingName); } /** * Convert settings from the internal representation to the string representation used in the * preference storage. * * @param settings * The map of settings to convert. * @param settingDescriptions * The structure containing the {@link SettingsDescription} objects that will be used * to convert the setting values. * * @return The settings converted to the string representation used in the preference storage. */ public static Map<String, String> convert(Map<String, Object> settings, Map<String, TreeMap<Integer, SettingsDescription>> settingDescriptions) { Map<String, String> serializedSettings = new HashMap<>(); for (Entry<String, Object> setting : settings.entrySet()) { String settingName = setting.getKey(); Object internalValue = setting.getValue(); TreeMap<Integer, SettingsDescription> versionedSetting = settingDescriptions.get(settingName); Integer highestVersion = versionedSetting.lastKey(); SettingsDescription settingDesc = versionedSetting.get(highestVersion); if (settingDesc != null) { String stringValue = settingDesc.toString(internalValue); serializedSettings.put(settingName, stringValue); } else { Timber.w("Settings.convert() called with a setting that should have been removed: %s", settingName); } } return serializedSettings; } /** * Creates a {@link TreeMap} linking version numbers to {@link SettingsDescription} instances. * * <p> * This {@code TreeMap} is used to quickly find the {@code SettingsDescription} belonging to a * content version as read by {@link SettingsImporter}. See e.g. * {@link Settings#validate(int, Map, Map, boolean)}. * </p> * * @param versionDescriptions * A list of descriptions for a specific setting mapped to version numbers. Never * {@code null}. * * @return A {@code TreeMap} using the version number as key, the {@code SettingsDescription} * as value. */ static TreeMap<Integer, SettingsDescription> versions(V... versionDescriptions) { TreeMap<Integer, SettingsDescription> map = new TreeMap<>(); for (V v : versionDescriptions) { map.put(v.version, v.description); } return map; } static class InvalidSettingValueException extends Exception { private static final long serialVersionUID = 1L; } /** * Describes a setting. * * <p> * Instances of this class are used to convert the string representations of setting values to * an internal representation (e.g. an integer) and back. * </p><p> * Currently we use two different string representations: * </p> * <ol> * <li> * The one that is used by the internal preference {@link Storage}. It is usually obtained by * calling {@code toString()} on the internal representation of the setting value (see e.g. * {@link K9#save(StorageEditor)}). * </li> * <li> * The "pretty" version that is used by the import/export settings file (e.g. colors are * exported in #rrggbb format instead of a integer string like "-8734021"). * </li> * </ol> * <p> * <strong>Note:</strong> * For the future we should aim to get rid of the "internal" string representation. The * "pretty" version makes reading a database dump easier and the performance impact should be * negligible. * </p> */ abstract static class SettingsDescription<T> { /** * The setting's default value (internal representation). */ T defaultValue; SettingsDescription(T defaultValue) { this.defaultValue = defaultValue; } /** * Get the default value. * * @return The internal representation of the default value. */ public T getDefaultValue() { return defaultValue; } /** * Convert a setting's value to the string representation. * * @param value * The internal representation of a setting's value. * * @return The string representation of {@code value}. */ public String toString(T value) { return value.toString(); } /** * Parse the string representation of a setting's value . * * @param value * The string representation of a setting's value. * * @return The internal representation of the setting's value. * * @throws InvalidSettingValueException * If {@code value} contains an invalid value. */ public abstract T fromString(String value) throws InvalidSettingValueException; /** * Convert a setting value to the "pretty" string representation. * * @param value * The setting's value. * * @return A pretty-printed version of the setting's value. */ public String toPrettyString(T value) { return toString(value); } /** * Convert the pretty-printed version of a setting's value to the internal representation. * * @param value * The pretty-printed version of the setting's value. See * {@link #toPrettyString(Object)}. * * @return The internal representation of the setting's value. * * @throws InvalidSettingValueException * If {@code value} contains an invalid value. */ public T fromPrettyString(String value) throws InvalidSettingValueException { return fromString(value); } } public static class V { public final Integer version; public final SettingsDescription description; V(Integer version, SettingsDescription description) { this.version = version; this.description = description; } } /** * Used for a nontrivial settings upgrade. * * @see Settings#upgrade(int, Map, Map, Map) */ interface SettingsUpgrader { /** * Upgrade the provided settings. * * @param settings * The settings to upgrade. This map is modified and contains the upgraded * settings when this method returns. * * @return A set of setting names that were removed during the upgrade process or * {@code null} if none were removed. */ Set<String> upgrade(Map<String, Object> settings); } static class StringSetting extends SettingsDescription<String> { StringSetting(String defaultValue) { super(defaultValue); } @Override public String fromString(String value) { return value; } } static class BooleanSetting extends SettingsDescription<Boolean> { BooleanSetting(boolean defaultValue) { super(defaultValue); } @Override public Boolean fromString(String value) throws InvalidSettingValueException { if (Boolean.TRUE.toString().equals(value)) { return true; } else if (Boolean.FALSE.toString().equals(value)) { return false; } throw new InvalidSettingValueException(); } } static class ColorSetting extends SettingsDescription<Integer> { ColorSetting(int defaultValue) { super(defaultValue); } @Override public Integer fromString(String value) throws InvalidSettingValueException { try { return Integer.parseInt(value); } catch (NumberFormatException e) { throw new InvalidSettingValueException(); } } @Override public String toPrettyString(Integer value) { int color = value & 0x00FFFFFF; return String.format("#%06x", color); } @Override public Integer fromPrettyString(String value) throws InvalidSettingValueException { try { if (value.length() == 7) { return Integer.parseInt(value.substring(1), 16) | 0xFF000000; } } catch (NumberFormatException e) { /* do nothing */ } throw new InvalidSettingValueException(); } } static class EnumSetting<T extends Enum<T>> extends SettingsDescription<T> { private Class<T> enumClass; EnumSetting(Class<T> enumClass, T defaultValue) { super(defaultValue); this.enumClass = enumClass; } @Override public T fromString(String value) throws InvalidSettingValueException { try { return Enum.valueOf(enumClass, value); } catch (Exception e) { throw new InvalidSettingValueException(); } } } /** * A setting that has multiple valid values but doesn't use an {@link Enum} internally. * * @param <T> * The type of the internal representation (e.g. {@code Integer}). */ abstract static class PseudoEnumSetting<T> extends SettingsDescription<T> { PseudoEnumSetting(T defaultValue) { super(defaultValue); } protected abstract Map<T, String> getMapping(); @Override public String toPrettyString(T value) { return getMapping().get(value); } @Override public T fromPrettyString(String value) throws InvalidSettingValueException { for (Entry<T, String> entry : getMapping().entrySet()) { if (entry.getValue().equals(value)) { return entry.getKey(); } } throw new InvalidSettingValueException(); } } static class FontSizeSetting extends PseudoEnumSetting<Integer> { private final Map<Integer, String> mapping; FontSizeSetting(int defaultValue) { super(defaultValue); Map<Integer, String> mapping = new HashMap<>(); mapping.put(FontSizes.FONT_10SP, "tiniest"); mapping.put(FontSizes.FONT_12SP, "tiny"); mapping.put(FontSizes.SMALL, "smaller"); mapping.put(FontSizes.FONT_16SP, "small"); mapping.put(FontSizes.MEDIUM, "medium"); mapping.put(FontSizes.FONT_20SP, "large"); mapping.put(FontSizes.LARGE, "larger"); this.mapping = Collections.unmodifiableMap(mapping); } @Override protected Map<Integer, String> getMapping() { return mapping; } @Override public Integer fromString(String value) throws InvalidSettingValueException { try { Integer fontSize = Integer.parseInt(value); if (mapping.containsKey(fontSize)) { return fontSize; } } catch (NumberFormatException e) { /* do nothing */ } throw new InvalidSettingValueException(); } } static class WebFontSizeSetting extends PseudoEnumSetting<Integer> { private final Map<Integer, String> mapping; WebFontSizeSetting(int defaultValue) { super(defaultValue); Map<Integer, String> mapping = new HashMap<>(); mapping.put(1, "smallest"); mapping.put(2, "smaller"); mapping.put(3, "normal"); mapping.put(4, "larger"); mapping.put(5, "largest"); this.mapping = Collections.unmodifiableMap(mapping); } @Override protected Map<Integer, String> getMapping() { return mapping; } @Override public Integer fromString(String value) throws InvalidSettingValueException { try { Integer fontSize = Integer.parseInt(value); if (mapping.containsKey(fontSize)) { return fontSize; } } catch (NumberFormatException e) { /* do nothing */ } throw new InvalidSettingValueException(); } } static class IntegerRangeSetting extends SettingsDescription<Integer> { private int start; private int end; IntegerRangeSetting(int start, int end, int defaultValue) { super(defaultValue); this.start = start; this.end = end; } @Override public Integer fromString(String value) throws InvalidSettingValueException { try { int intValue = Integer.parseInt(value); if (start <= intValue && intValue <= end) { return intValue; } } catch (NumberFormatException e) { /* do nothing */ } throw new InvalidSettingValueException(); } } }