package com.fsck.k9droidtn.preferences; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.SortedMap; import java.util.TreeMap; import java.util.Map.Entry; import android.util.Log; import com.fsck.k9droidtn.FontSizes; import com.fsck.k9droidtn.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 = 10; public 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<String, Object>(); 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.size() == 0) { 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)) { Log.v(K9.LOG_TAG, "Key \"" + key + "\" wasn't found in the imported file." + ((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) { Log.v(K9.LOG_TAG, "Key \"" + key + "\" has invalid value \"" + prettyValue + "\" in imported file. " + ((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 validatedSettings}. * @param upgraders * A map of {@link SettingsUpgrader}s for nontrivial settings upgrades. * @param settings * The structure describing the different settings, possibly containing multiple * versions. * @param validatedSettings * 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> upgraders, Map<String, TreeMap<Integer, SettingsDescription>> settings, Map<String, Object> validatedSettings) { Map<String, Object> upgradedSettings = validatedSettings; Set<String> deletedSettings = null; for (int toVersion = version + 1; toVersion <= VERSION; toVersion++) { // Check if there's an SettingsUpgrader for that version SettingsUpgrader upgrader = upgraders.get(toVersion); if (upgrader != null) { deletedSettings = upgrader.upgrade(upgradedSettings); } // Deal with settings that don't need special upgrade code for (Entry<String, TreeMap<Integer, SettingsDescription>> versions : settings.entrySet()) { String settingName = versions.getKey(); TreeMap<Integer, SettingsDescription> versionedSettings = versions.getValue(); // Handle newly added settings if (versionedSettings.firstKey().intValue() == toVersion) { // Check if it was already added to upgradedSettings by the SettingsUpgrader if (!upgradedSettings.containsKey(settingName)) { // Insert default value to upgradedSettings SettingsDescription setting = versionedSettings.get(toVersion); Object defaultValue = setting.getDefaultValue(); upgradedSettings.put(settingName, defaultValue); if (K9.DEBUG) { String prettyValue = setting.toPrettyString(defaultValue); Log.v(K9.LOG_TAG, "Added new setting \"" + settingName + "\" with default value \"" + prettyValue + "\""); } } } // Handle removed settings Integer highestVersion = versionedSettings.lastKey(); if (highestVersion.intValue() == toVersion && versionedSettings.get(highestVersion) == null) { upgradedSettings.remove(settingName); if (deletedSettings == null) { deletedSettings = new HashSet<String>(); } deletedSettings.add(settingName); if (K9.DEBUG) { Log.v(K9.LOG_TAG, "Removed setting \"" + settingName + "\""); } } } } return deletedSettings; } /** * 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<String, String>(); 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 { if (K9.DEBUG) { Log.w(K9.LOG_TAG, "Settings.serialize() called with a setting that should " + "have been removed: " + 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. */ public static TreeMap<Integer, SettingsDescription> versions( V... versionDescriptions) { TreeMap<Integer, SettingsDescription> map = new TreeMap<Integer, SettingsDescription>(); for (V v : versionDescriptions) { map.put(v.version, v.description); } return map; } /** * Indicates an invalid setting value. * * @see SettingsDescription#fromString(String) * @see SettingsDescription#fromPrettyString(String) */ public 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(android.content.SharedPreferences.Editor)}). * </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> */ public static abstract class SettingsDescription { /** * The setting's default value (internal representation). */ protected Object mDefaultValue; public SettingsDescription(Object defaultValue) { mDefaultValue = defaultValue; } /** * Get the default value. * * @return The internal representation of the default value. */ public Object getDefaultValue() { return mDefaultValue; } /** * 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(Object 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 Object 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(Object 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 Object fromPrettyString(String value) throws InvalidSettingValueException { return fromString(value); } } /** * Container to hold a {@link SettingsDescription} instance and a version number. * * @see Settings#versions(V...) */ public static class V { public final Integer version; public final SettingsDescription description; public V(Integer version, SettingsDescription description) { this.version = version; this.description = description; } } /** * Used for a nontrivial settings upgrade. * * @see Settings#upgrade(int, Map, Map, Map) */ public 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. */ public Set<String> upgrade(Map<String, Object> settings); } /** * A string setting. */ public static class StringSetting extends SettingsDescription { public StringSetting(String defaultValue) { super(defaultValue); } @Override public Object fromString(String value) { return value; } } /** * A boolean setting. */ public static class BooleanSetting extends SettingsDescription { public BooleanSetting(boolean defaultValue) { super(defaultValue); } @Override public Object 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(); } } /** * A color setting. */ public static class ColorSetting extends SettingsDescription { public ColorSetting(int defaultValue) { super(defaultValue); } @Override public Object fromString(String value) throws InvalidSettingValueException { try { return Integer.parseInt(value); } catch (NumberFormatException e) { throw new InvalidSettingValueException(); } } @Override public String toPrettyString(Object value) { int color = ((Integer) value) & 0x00FFFFFF; return String.format("#%06x", color); } @Override public Object 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(); } } /** * An {@code Enum} setting. * * <p> * {@link Enum#toString()} is used to obtain the "pretty" string representation. * </p> */ public static class EnumSetting extends SettingsDescription { private Class<? extends Enum<?>> mEnumClass; public EnumSetting(Class<? extends Enum<?>> enumClass, Object defaultValue) { super(defaultValue); mEnumClass = enumClass; } @SuppressWarnings("unchecked") @Override public Object fromString(String value) throws InvalidSettingValueException { try { return Enum.valueOf((Class<? extends Enum>)mEnumClass, value); } catch (Exception e) { throw new InvalidSettingValueException(); } } } /** * A setting that has multiple valid values but doesn't use an {@link Enum} internally. * * @param <A> * The type of the internal representation (e.g. {@code Integer}). */ public abstract static class PseudoEnumSetting<A> extends SettingsDescription { public PseudoEnumSetting(Object defaultValue) { super(defaultValue); } protected abstract Map<A, String> getMapping(); @Override public String toPrettyString(Object value) { return getMapping().get(value); } @Override public Object fromPrettyString(String value) throws InvalidSettingValueException { for (Entry<A, String> entry : getMapping().entrySet()) { if (entry.getValue().equals(value)) { return entry.getKey(); } } throw new InvalidSettingValueException(); } } /** * A font size setting. */ public static class FontSizeSetting extends PseudoEnumSetting<Integer> { private final Map<Integer, String> mMapping; public FontSizeSetting(int defaultValue) { super(defaultValue); Map<Integer, String> mapping = new HashMap<Integer, String>(); 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"); mMapping = Collections.unmodifiableMap(mapping); } @Override protected Map<Integer, String> getMapping() { return mMapping; } @Override public Object fromString(String value) throws InvalidSettingValueException { try { Integer fontSize = Integer.parseInt(value); if (mMapping.containsKey(fontSize)) { return fontSize; } } catch (NumberFormatException e) { /* do nothing */ } throw new InvalidSettingValueException(); } } /** * A {@link android.webkit.WebView} font size setting. */ public static class WebFontSizeSetting extends PseudoEnumSetting<Integer> { private final Map<Integer, String> mMapping; public WebFontSizeSetting(int defaultValue) { super(defaultValue); Map<Integer, String> mapping = new HashMap<Integer, String>(); mapping.put(1, "smallest"); mapping.put(2, "smaller"); mapping.put(3, "normal"); mapping.put(4, "larger"); mapping.put(5, "largest"); mMapping = Collections.unmodifiableMap(mapping); } @Override protected Map<Integer, String> getMapping() { return mMapping; } @Override public Object fromString(String value) throws InvalidSettingValueException { try { Integer fontSize = Integer.parseInt(value); if (mMapping.containsKey(fontSize)) { return fontSize; } } catch (NumberFormatException e) { /* do nothing */ } throw new InvalidSettingValueException(); } } /** * An integer settings whose values a limited to a certain range. */ public static class IntegerRangeSetting extends SettingsDescription { private int mStart; private int mEnd; public IntegerRangeSetting(int start, int end, int defaultValue) { super(defaultValue); mStart = start; mEnd = end; } @Override public Object fromString(String value) throws InvalidSettingValueException { try { int intValue = Integer.parseInt(value); if (mStart <= intValue && intValue <= mEnd) { return intValue; } } catch (NumberFormatException e) { /* do nothing */ } throw new InvalidSettingValueException(); } } }