001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data;
003
004import static org.openstreetmap.josm.tools.I18n.marktr;
005import static org.openstreetmap.josm.tools.I18n.tr;
006
007import java.io.File;
008import java.io.IOException;
009import java.io.PrintWriter;
010import java.io.Reader;
011import java.io.StringWriter;
012import java.nio.charset.StandardCharsets;
013import java.util.ArrayList;
014import java.util.Collection;
015import java.util.Collections;
016import java.util.HashMap;
017import java.util.HashSet;
018import java.util.Iterator;
019import java.util.LinkedList;
020import java.util.List;
021import java.util.Map;
022import java.util.Map.Entry;
023import java.util.Optional;
024import java.util.Set;
025import java.util.SortedMap;
026import java.util.TreeMap;
027import java.util.concurrent.TimeUnit;
028import java.util.function.Predicate;
029import java.util.stream.Stream;
030
031import javax.swing.JOptionPane;
032import javax.xml.stream.XMLStreamException;
033
034import org.openstreetmap.josm.Main;
035import org.openstreetmap.josm.data.preferences.ColorInfo;
036import org.openstreetmap.josm.data.preferences.NamedColorProperty;
037import org.openstreetmap.josm.data.preferences.PreferencesReader;
038import org.openstreetmap.josm.data.preferences.PreferencesWriter;
039import org.openstreetmap.josm.io.OfflineAccessException;
040import org.openstreetmap.josm.io.OnlineResource;
041import org.openstreetmap.josm.spi.preferences.AbstractPreferences;
042import org.openstreetmap.josm.spi.preferences.Config;
043import org.openstreetmap.josm.spi.preferences.IBaseDirectories;
044import org.openstreetmap.josm.spi.preferences.ListSetting;
045import org.openstreetmap.josm.spi.preferences.Setting;
046import org.openstreetmap.josm.spi.preferences.StringSetting;
047import org.openstreetmap.josm.tools.CheckParameterUtil;
048import org.openstreetmap.josm.tools.ListenerList;
049import org.openstreetmap.josm.tools.Logging;
050import org.openstreetmap.josm.tools.Utils;
051import org.xml.sax.SAXException;
052
053/**
054 * This class holds all preferences for JOSM.
055 *
056 * Other classes can register their beloved properties here. All properties will be
057 * saved upon set-access.
058 *
059 * Each property is a key=setting pair, where key is a String and setting can be one of
060 * 4 types:
061 *     string, list, list of lists and list of maps.
062 * In addition, each key has a unique default value that is set when the value is first
063 * accessed using one of the get...() methods. You can use the same preference
064 * key in different parts of the code, but the default value must be the same
065 * everywhere. A default value of null means, the setting has been requested, but
066 * no default value was set. This is used in advanced preferences to present a list
067 * off all possible settings.
068 *
069 * At the moment, you cannot put the empty string for string properties.
070 * put(key, "") means, the property is removed.
071 *
072 * @author imi
073 * @since 74
074 */
075public class Preferences extends AbstractPreferences {
076
077    private static final String[] OBSOLETE_PREF_KEYS = {
078    };
079
080    private static final long MAX_AGE_DEFAULT_PREFERENCES = TimeUnit.DAYS.toSeconds(50);
081
082    private final IBaseDirectories dirs;
083
084    /**
085     * Determines if preferences file is saved each time a property is changed.
086     */
087    private boolean saveOnPut = true;
088
089    /**
090     * Maps the setting name to the current value of the setting.
091     * The map must not contain null as key or value. The mapped setting objects
092     * must not have a null value.
093     */
094    protected final SortedMap<String, Setting<?>> settingsMap = new TreeMap<>();
095
096    /**
097     * Maps the setting name to the default value of the setting.
098     * The map must not contain null as key or value. The value of the mapped
099     * setting objects can be null.
100     */
101    protected final SortedMap<String, Setting<?>> defaultsMap = new TreeMap<>();
102
103    private final Predicate<Entry<String, Setting<?>>> NO_DEFAULT_SETTINGS_ENTRY =
104            e -> !e.getValue().equals(defaultsMap.get(e.getKey()));
105
106    /**
107     * Indicates whether {@link #init(boolean)} completed successfully.
108     * Used to decide whether to write backup preference file in {@link #save()}
109     */
110    protected boolean initSuccessful;
111
112    private final ListenerList<org.openstreetmap.josm.spi.preferences.PreferenceChangedListener> listeners = ListenerList.create();
113
114    private final HashMap<String, ListenerList<org.openstreetmap.josm.spi.preferences.PreferenceChangedListener>> keyListeners = new HashMap<>();
115
116    /**
117     * Constructs a new {@code Preferences}.
118     */
119    public Preferences() {
120        this.dirs = Config.getDirs();
121    }
122
123    /**
124     * Constructs a new {@code Preferences}.
125     *
126     * @param dirs the directories to use for saving the preferences
127     */
128    public Preferences(IBaseDirectories dirs) {
129        this.dirs = dirs;
130    }
131
132    /**
133     * Constructs a new {@code Preferences} from an existing instance.
134     * @param pref existing preferences to copy
135     * @since 12634
136     */
137    public Preferences(Preferences pref) {
138        this(pref.dirs);
139        settingsMap.putAll(pref.settingsMap);
140        defaultsMap.putAll(pref.defaultsMap);
141    }
142
143    /**
144     * Adds a new preferences listener.
145     * @param listener The listener to add
146     * @since 12881
147     */
148    @Override
149    public void addPreferenceChangeListener(org.openstreetmap.josm.spi.preferences.PreferenceChangedListener listener) {
150        if (listener != null) {
151            listeners.addListener(listener);
152        }
153    }
154
155    /**
156     * Removes a preferences listener.
157     * @param listener The listener to remove
158     * @since 12881
159     */
160    @Override
161    public void removePreferenceChangeListener(org.openstreetmap.josm.spi.preferences.PreferenceChangedListener listener) {
162        listeners.removeListener(listener);
163    }
164
165    /**
166     * Adds a listener that only listens to changes in one preference
167     * @param key The preference key to listen to
168     * @param listener The listener to add.
169     * @since 12881
170     */
171    @Override
172    public void addKeyPreferenceChangeListener(String key, org.openstreetmap.josm.spi.preferences.PreferenceChangedListener listener) {
173        listenersForKey(key).addListener(listener);
174    }
175
176    /**
177     * Adds a weak listener that only listens to changes in one preference
178     * @param key The preference key to listen to
179     * @param listener The listener to add.
180     * @since 10824
181     */
182    public void addWeakKeyPreferenceChangeListener(String key, org.openstreetmap.josm.spi.preferences.PreferenceChangedListener listener) {
183        listenersForKey(key).addWeakListener(listener);
184    }
185
186    private ListenerList<org.openstreetmap.josm.spi.preferences.PreferenceChangedListener> listenersForKey(String key) {
187        return keyListeners.computeIfAbsent(key, k -> ListenerList.create());
188    }
189
190    /**
191     * Removes a listener that only listens to changes in one preference
192     * @param key The preference key to listen to
193     * @param listener The listener to add.
194     * @since 12881
195     */
196    @Override
197    public void removeKeyPreferenceChangeListener(String key, org.openstreetmap.josm.spi.preferences.PreferenceChangedListener listener) {
198        Optional.ofNullable(keyListeners.get(key)).orElseThrow(
199                () -> new IllegalArgumentException("There are no listeners registered for " + key))
200        .removeListener(listener);
201    }
202
203    protected void firePreferenceChanged(String key, Setting<?> oldValue, Setting<?> newValue) {
204        final org.openstreetmap.josm.spi.preferences.PreferenceChangeEvent evt =
205                new org.openstreetmap.josm.spi.preferences.DefaultPreferenceChangeEvent(key, oldValue, newValue);
206        listeners.fireEvent(listener -> listener.preferenceChanged(evt));
207
208        ListenerList<org.openstreetmap.josm.spi.preferences.PreferenceChangedListener> forKey = keyListeners.get(key);
209        if (forKey != null) {
210            forKey.fireEvent(listener -> listener.preferenceChanged(evt));
211        }
212    }
213
214    /**
215     * Get the base name of the JOSM directories for preferences, cache and user data.
216     * Default value is "JOSM", unless overridden by system property "josm.dir.name".
217     * @return the base name of the JOSM directories for preferences, cache and user data
218     */
219    public String getJOSMDirectoryBaseName() {
220        String name = System.getProperty("josm.dir.name");
221        if (name != null)
222            return name;
223        else
224            return "JOSM";
225    }
226
227    /**
228     * Get the base directories associated with this preference instance.
229     * @return the base directories
230     */
231    public IBaseDirectories getDirs() {
232        return dirs;
233    }
234
235    /**
236     * Returns the user defined preferences directory, containing the preferences.xml file
237     * @return The user defined preferences directory, containing the preferences.xml file
238     * @since 7834
239     * @deprecated use {@link #getPreferencesDirectory(boolean)}
240     */
241    @Deprecated
242    public File getPreferencesDirectory() {
243        return getPreferencesDirectory(false);
244    }
245
246    /**
247     * @param createIfMissing if true, automatically creates this directory,
248     * in case it is missing
249     * @return the preferences directory
250     * @deprecated use {@link #getDirs()} or (more generally) {@link Config#getDirs()}
251     */
252    @Deprecated
253    public File getPreferencesDirectory(boolean createIfMissing) {
254        return dirs.getPreferencesDirectory(createIfMissing);
255    }
256
257    /**
258     * Returns the user data directory, containing autosave, plugins, etc.
259     * Depending on the OS it may be the same directory as preferences directory.
260     * @return The user data directory, containing autosave, plugins, etc.
261     * @since 7834
262     * @deprecated use {@link #getUserDataDirectory(boolean)}
263     */
264    @Deprecated
265    public File getUserDataDirectory() {
266        return getUserDataDirectory(false);
267    }
268
269    /**
270     * @param createIfMissing if true, automatically creates this directory,
271     * in case it is missing
272     * @return the user data directory
273     * @deprecated use {@link #getDirs()} or (more generally) {@link Config#getDirs()}
274     */
275    @Deprecated
276    public File getUserDataDirectory(boolean createIfMissing) {
277        return dirs.getUserDataDirectory(createIfMissing);
278    }
279
280    /**
281     * Returns the user preferences file (preferences.xml).
282     * @return The user preferences file (preferences.xml)
283     */
284    public File getPreferenceFile() {
285        return new File(dirs.getPreferencesDirectory(false), "preferences.xml");
286    }
287
288    /**
289     * Returns the cache file for default preferences.
290     * @return the cache file for default preferences
291     */
292    public File getDefaultsCacheFile() {
293        return new File(dirs.getCacheDirectory(true), "default_preferences.xml");
294    }
295
296    /**
297     * Returns the user plugin directory.
298     * @return The user plugin directory
299     */
300    public File getPluginsDirectory() {
301        return new File(dirs.getUserDataDirectory(false), "plugins");
302    }
303
304    /**
305     * Get the directory where cached content of any kind should be stored.
306     *
307     * If the directory doesn't exist on the file system, it will be created by this method.
308     *
309     * @return the cache directory
310     * @deprecated use {@link #getCacheDirectory(boolean)}
311     */
312    @Deprecated
313    public File getCacheDirectory() {
314        return getCacheDirectory(true);
315    }
316
317    /**
318     * @param createIfMissing if true, automatically creates this directory,
319     * in case it is missing
320     * @return the cache directory
321     * @deprecated use {@link #getDirs()} or (more generally) {@link Config#getDirs()}
322     */
323    @Deprecated
324    public File getCacheDirectory(boolean createIfMissing) {
325        return dirs.getCacheDirectory(createIfMissing);
326    }
327
328    private static void addPossibleResourceDir(Set<String> locations, String s) {
329        if (s != null) {
330            if (!s.endsWith(File.separator)) {
331                s += File.separator;
332            }
333            locations.add(s);
334        }
335    }
336
337    /**
338     * Returns a set of all existing directories where resources could be stored.
339     * @return A set of all existing directories where resources could be stored.
340     */
341    public Collection<String> getAllPossiblePreferenceDirs() {
342        Set<String> locations = new HashSet<>();
343        addPossibleResourceDir(locations, dirs.getPreferencesDirectory(false).getPath());
344        addPossibleResourceDir(locations, dirs.getUserDataDirectory(false).getPath());
345        addPossibleResourceDir(locations, System.getenv("JOSM_RESOURCES"));
346        addPossibleResourceDir(locations, System.getProperty("josm.resources"));
347        if (Main.isPlatformWindows()) {
348            String appdata = System.getenv("APPDATA");
349            if (appdata != null && System.getenv("ALLUSERSPROFILE") != null
350                    && appdata.lastIndexOf(File.separator) != -1) {
351                appdata = appdata.substring(appdata.lastIndexOf(File.separator));
352                locations.add(new File(new File(System.getenv("ALLUSERSPROFILE"),
353                        appdata), "JOSM").getPath());
354            }
355        } else {
356            locations.add("/usr/local/share/josm/");
357            locations.add("/usr/local/lib/josm/");
358            locations.add("/usr/share/josm/");
359            locations.add("/usr/lib/josm/");
360        }
361        return locations;
362    }
363
364    /**
365     * Gets all normal (string) settings that have a key starting with the prefix
366     * @param prefix The start of the key
367     * @return The key names of the settings
368     */
369    public synchronized Map<String, String> getAllPrefix(final String prefix) {
370        final Map<String, String> all = new TreeMap<>();
371        for (final Entry<String, Setting<?>> e : settingsMap.entrySet()) {
372            if (e.getKey().startsWith(prefix) && (e.getValue() instanceof StringSetting)) {
373                all.put(e.getKey(), ((StringSetting) e.getValue()).getValue());
374            }
375        }
376        return all;
377    }
378
379    /**
380     * Gets all list settings that have a key starting with the prefix
381     * @param prefix The start of the key
382     * @return The key names of the list settings
383     */
384    public synchronized List<String> getAllPrefixCollectionKeys(final String prefix) {
385        final List<String> all = new LinkedList<>();
386        for (Map.Entry<String, Setting<?>> entry : settingsMap.entrySet()) {
387            if (entry.getKey().startsWith(prefix) && entry.getValue() instanceof ListSetting) {
388                all.add(entry.getKey());
389            }
390        }
391        return all;
392    }
393
394    /**
395     * Get all named colors, including customized and the default ones.
396     * @return a map of all named colors (maps preference key to {@link ColorInfo})
397     */
398    public synchronized Map<String, ColorInfo> getAllNamedColors() {
399        final Map<String, ColorInfo> all = new TreeMap<>();
400        for (final Entry<String, Setting<?>> e : settingsMap.entrySet()) {
401            if (!e.getKey().startsWith(NamedColorProperty.NAMED_COLOR_PREFIX))
402                continue;
403            Utils.instanceOfAndCast(e.getValue(), ListSetting.class)
404                    .map(d -> d.getValue())
405                    .map(lst -> ColorInfo.fromPref(lst, false))
406                    .ifPresent(info -> all.put(e.getKey(), info));
407        }
408        for (final Entry<String, Setting<?>> e : defaultsMap.entrySet()) {
409            if (!e.getKey().startsWith(NamedColorProperty.NAMED_COLOR_PREFIX))
410                continue;
411            Utils.instanceOfAndCast(e.getValue(), ListSetting.class)
412                    .map(d -> d.getValue())
413                    .map(lst -> ColorInfo.fromPref(lst, true))
414                    .ifPresent(infoDef -> {
415                        ColorInfo info = all.get(e.getKey());
416                        if (info == null) {
417                            all.put(e.getKey(), infoDef);
418                        } else {
419                            info.setDefaultValue(infoDef.getDefaultValue());
420                        }
421                    });
422        }
423        return all;
424    }
425
426    /**
427     * Called after every put. In case of a problem, do nothing but output the error in log.
428     * @throws IOException if any I/O error occurs
429     */
430    public synchronized void save() throws IOException {
431        save(getPreferenceFile(), settingsMap.entrySet().stream().filter(NO_DEFAULT_SETTINGS_ENTRY), false);
432    }
433
434    /**
435     * Stores the defaults to the defaults file
436     * @throws IOException If the file could not be saved
437     */
438    public synchronized void saveDefaults() throws IOException {
439        save(getDefaultsCacheFile(), defaultsMap.entrySet().stream(), true);
440    }
441
442    protected void save(File prefFile, Stream<Entry<String, Setting<?>>> settings, boolean defaults) throws IOException {
443        if (!defaults) {
444            /* currently unused, but may help to fix configuration issues in future */
445            putInt("josm.version", Version.getInstance().getVersion());
446        }
447
448        File backupFile = new File(prefFile + "_backup");
449
450        // Backup old preferences if there are old preferences
451        if (initSuccessful && prefFile.exists() && prefFile.length() > 0) {
452            Utils.copyFile(prefFile, backupFile);
453        }
454
455        try (PreferencesWriter writer = new PreferencesWriter(
456                new PrintWriter(new File(prefFile + "_tmp"), StandardCharsets.UTF_8.name()), false, defaults)) {
457            writer.write(settings);
458        }
459
460        File tmpFile = new File(prefFile + "_tmp");
461        Utils.copyFile(tmpFile, prefFile);
462        Utils.deleteFile(tmpFile, marktr("Unable to delete temporary file {0}"));
463
464        setCorrectPermissions(prefFile);
465        setCorrectPermissions(backupFile);
466    }
467
468    private static void setCorrectPermissions(File file) {
469        if (!file.setReadable(false, false) && Logging.isTraceEnabled()) {
470            Logging.trace(tr("Unable to set file non-readable {0}", file.getAbsolutePath()));
471        }
472        if (!file.setWritable(false, false) && Logging.isTraceEnabled()) {
473            Logging.trace(tr("Unable to set file non-writable {0}", file.getAbsolutePath()));
474        }
475        if (!file.setExecutable(false, false) && Logging.isTraceEnabled()) {
476            Logging.trace(tr("Unable to set file non-executable {0}", file.getAbsolutePath()));
477        }
478        if (!file.setReadable(true, true) && Logging.isTraceEnabled()) {
479            Logging.trace(tr("Unable to set file readable {0}", file.getAbsolutePath()));
480        }
481        if (!file.setWritable(true, true) && Logging.isTraceEnabled()) {
482            Logging.trace(tr("Unable to set file writable {0}", file.getAbsolutePath()));
483        }
484    }
485
486    /**
487     * Loads preferences from settings file.
488     * @throws IOException if any I/O error occurs while reading the file
489     * @throws SAXException if the settings file does not contain valid XML
490     * @throws XMLStreamException if an XML error occurs while parsing the file (after validation)
491     */
492    protected void load() throws IOException, SAXException, XMLStreamException {
493        File pref = getPreferenceFile();
494        PreferencesReader.validateXML(pref);
495        PreferencesReader reader = new PreferencesReader(pref, false);
496        reader.parse();
497        settingsMap.clear();
498        settingsMap.putAll(reader.getSettings());
499        removeObsolete(reader.getVersion());
500    }
501
502    /**
503     * Loads default preferences from default settings cache file.
504     *
505     * Discards entries older than {@link #MAX_AGE_DEFAULT_PREFERENCES}.
506     *
507     * @throws IOException if any I/O error occurs while reading the file
508     * @throws SAXException if the settings file does not contain valid XML
509     * @throws XMLStreamException if an XML error occurs while parsing the file (after validation)
510     */
511    protected void loadDefaults() throws IOException, XMLStreamException, SAXException {
512        File def = getDefaultsCacheFile();
513        PreferencesReader.validateXML(def);
514        PreferencesReader reader = new PreferencesReader(def, true);
515        reader.parse();
516        defaultsMap.clear();
517        long minTime = System.currentTimeMillis() / 1000 - MAX_AGE_DEFAULT_PREFERENCES;
518        for (Entry<String, Setting<?>> e : reader.getSettings().entrySet()) {
519            if (e.getValue().getTime() >= minTime) {
520                defaultsMap.put(e.getKey(), e.getValue());
521            }
522        }
523    }
524
525    /**
526     * Loads preferences from XML reader.
527     * @param in XML reader
528     * @throws XMLStreamException if any XML stream error occurs
529     * @throws IOException if any I/O error occurs
530     */
531    public void fromXML(Reader in) throws XMLStreamException, IOException {
532        PreferencesReader reader = new PreferencesReader(in, false);
533        reader.parse();
534        settingsMap.clear();
535        settingsMap.putAll(reader.getSettings());
536    }
537
538    /**
539     * Initializes preferences.
540     * @param reset if {@code true}, current settings file is replaced by the default one
541     */
542    public void init(boolean reset) {
543        initSuccessful = false;
544        // get the preferences.
545        File prefDir = dirs.getPreferencesDirectory(false);
546        if (prefDir.exists()) {
547            if (!prefDir.isDirectory()) {
548                Logging.warn(tr("Failed to initialize preferences. Preference directory ''{0}'' is not a directory.",
549                        prefDir.getAbsoluteFile()));
550                JOptionPane.showMessageDialog(
551                        Main.parent,
552                        tr("<html>Failed to initialize preferences.<br>Preference directory ''{0}'' is not a directory.</html>",
553                                prefDir.getAbsoluteFile()),
554                        tr("Error"),
555                        JOptionPane.ERROR_MESSAGE
556                );
557                return;
558            }
559        } else {
560            if (!prefDir.mkdirs()) {
561                Logging.warn(tr("Failed to initialize preferences. Failed to create missing preference directory: {0}",
562                        prefDir.getAbsoluteFile()));
563                JOptionPane.showMessageDialog(
564                        Main.parent,
565                        tr("<html>Failed to initialize preferences.<br>Failed to create missing preference directory: {0}</html>",
566                                prefDir.getAbsoluteFile()),
567                        tr("Error"),
568                        JOptionPane.ERROR_MESSAGE
569                );
570                return;
571            }
572        }
573
574        File preferenceFile = getPreferenceFile();
575        try {
576            if (!preferenceFile.exists()) {
577                Logging.info(tr("Missing preference file ''{0}''. Creating a default preference file.", preferenceFile.getAbsoluteFile()));
578                resetToDefault();
579                save();
580            } else if (reset) {
581                File backupFile = new File(prefDir, "preferences.xml.bak");
582                Main.platform.rename(preferenceFile, backupFile);
583                Logging.warn(tr("Replacing existing preference file ''{0}'' with default preference file.", preferenceFile.getAbsoluteFile()));
584                resetToDefault();
585                save();
586            }
587        } catch (IOException e) {
588            Logging.error(e);
589            JOptionPane.showMessageDialog(
590                    Main.parent,
591                    tr("<html>Failed to initialize preferences.<br>Failed to reset preference file to default: {0}</html>",
592                            getPreferenceFile().getAbsoluteFile()),
593                    tr("Error"),
594                    JOptionPane.ERROR_MESSAGE
595            );
596            return;
597        }
598        try {
599            load();
600            initSuccessful = true;
601        } catch (IOException | SAXException | XMLStreamException e) {
602            Logging.error(e);
603            File backupFile = new File(prefDir, "preferences.xml.bak");
604            JOptionPane.showMessageDialog(
605                    Main.parent,
606                    tr("<html>Preferences file had errors.<br> Making backup of old one to <br>{0}<br> " +
607                            "and creating a new default preference file.</html>",
608                            backupFile.getAbsoluteFile()),
609                    tr("Error"),
610                    JOptionPane.ERROR_MESSAGE
611            );
612            Main.platform.rename(preferenceFile, backupFile);
613            try {
614                resetToDefault();
615                save();
616            } catch (IOException e1) {
617                Logging.error(e1);
618                Logging.warn(tr("Failed to initialize preferences. Failed to reset preference file to default: {0}", getPreferenceFile()));
619            }
620        }
621        File def = getDefaultsCacheFile();
622        if (def.exists()) {
623            try {
624                loadDefaults();
625            } catch (IOException | XMLStreamException | SAXException e) {
626                Logging.error(e);
627                Logging.warn(tr("Failed to load defaults cache file: {0}", def));
628                defaultsMap.clear();
629                if (!def.delete()) {
630                    Logging.warn(tr("Failed to delete faulty defaults cache file: {0}", def));
631                }
632            }
633        }
634    }
635
636    /**
637     * Resets the preferences to their initial state. This resets all values and file associations.
638     * The default values and listeners are not removed.
639     * <p>
640     * It is meant to be called before {@link #init(boolean)}
641     * @since 10876
642     */
643    public void resetToInitialState() {
644        resetToDefault();
645        saveOnPut = true;
646        initSuccessful = false;
647    }
648
649    /**
650     * Reset all values stored in this map to the default values. This clears the preferences.
651     */
652    public final void resetToDefault() {
653        settingsMap.clear();
654    }
655
656    /**
657     * Set a value for a certain setting. The changed setting is saved to the preference file immediately.
658     * Due to caching mechanisms on modern operating systems and hardware, this shouldn't be a performance problem.
659     * @param key the unique identifier for the setting
660     * @param setting the value of the setting. In case it is null, the key-value entry will be removed.
661     * @return {@code true}, if something has changed (i.e. value is different than before)
662     */
663    @Override
664    public boolean putSetting(final String key, Setting<?> setting) {
665        CheckParameterUtil.ensureParameterNotNull(key);
666        if (setting != null && setting.getValue() == null)
667            throw new IllegalArgumentException("setting argument must not have null value");
668        Setting<?> settingOld;
669        Setting<?> settingCopy = null;
670        synchronized (this) {
671            if (setting == null) {
672                settingOld = settingsMap.remove(key);
673                if (settingOld == null)
674                    return false;
675            } else {
676                settingOld = settingsMap.get(key);
677                if (setting.equals(settingOld))
678                    return false;
679                if (settingOld == null && setting.equals(defaultsMap.get(key)))
680                    return false;
681                settingCopy = setting.copy();
682                settingsMap.put(key, settingCopy);
683            }
684            if (saveOnPut) {
685                try {
686                    save();
687                } catch (IOException e) {
688                    Logging.log(Logging.LEVEL_WARN, tr("Failed to persist preferences to ''{0}''", getPreferenceFile().getAbsoluteFile()), e);
689                }
690            }
691        }
692        // Call outside of synchronized section in case some listener wait for other thread that wait for preference lock
693        firePreferenceChanged(key, settingOld, settingCopy);
694        return true;
695    }
696
697    /**
698     * Get a setting of any type
699     * @param key The key for the setting
700     * @param def The default value to use if it was not found
701     * @return The setting
702     */
703    public synchronized Setting<?> getSetting(String key, Setting<?> def) {
704        return getSetting(key, def, Setting.class);
705    }
706
707    /**
708     * Get settings value for a certain key and provide default a value.
709     * @param <T> the setting type
710     * @param key the identifier for the setting
711     * @param def the default value. For each call of getSetting() with a given key, the default value must be the same.
712     * <code>def</code> must not be null, but the value of <code>def</code> can be null.
713     * @param klass the setting type (same as T)
714     * @return the corresponding value if the property has been set before, {@code def} otherwise
715     */
716    @SuppressWarnings("unchecked")
717    @Override
718    public synchronized <T extends Setting<?>> T getSetting(String key, T def, Class<T> klass) {
719        CheckParameterUtil.ensureParameterNotNull(key);
720        CheckParameterUtil.ensureParameterNotNull(def);
721        Setting<?> oldDef = defaultsMap.get(key);
722        if (oldDef != null && oldDef.isNew() && oldDef.getValue() != null && def.getValue() != null && !def.equals(oldDef)) {
723            Logging.info("Defaults for " + key + " differ: " + def + " != " + defaultsMap.get(key));
724        }
725        if (def.getValue() != null || oldDef == null) {
726            Setting<?> defCopy = def.copy();
727            defCopy.setTime(System.currentTimeMillis() / 1000);
728            defCopy.setNew(true);
729            defaultsMap.put(key, defCopy);
730        }
731        Setting<?> prop = settingsMap.get(key);
732        if (klass.isInstance(prop)) {
733            return (T) prop;
734        } else {
735            return def;
736        }
737    }
738
739    @Override
740    public Set<String> getKeySet() {
741        return Collections.unmodifiableSet(settingsMap.keySet());
742    }
743
744    /**
745     * Gets a map of all settings that are currently stored
746     * @return The settings
747     */
748    public Map<String, Setting<?>> getAllSettings() {
749        return new TreeMap<>(settingsMap);
750    }
751
752    /**
753     * Gets a map of all currently known defaults
754     * @return The map (key/setting)
755     */
756    public Map<String, Setting<?>> getAllDefaults() {
757        return new TreeMap<>(defaultsMap);
758    }
759
760    /**
761     * Replies the collection of plugin site URLs from where plugin lists can be downloaded.
762     * @return the collection of plugin site URLs
763     * @see #getOnlinePluginSites
764     */
765    public Collection<String> getPluginSites() {
766        return getList("pluginmanager.sites", Collections.singletonList(Main.getJOSMWebsite()+"/pluginicons%<?plugins=>"));
767    }
768
769    /**
770     * Returns the list of plugin sites available according to offline mode settings.
771     * @return the list of available plugin sites
772     * @since 8471
773     */
774    public Collection<String> getOnlinePluginSites() {
775        Collection<String> pluginSites = new ArrayList<>(getPluginSites());
776        for (Iterator<String> it = pluginSites.iterator(); it.hasNext();) {
777            try {
778                OnlineResource.JOSM_WEBSITE.checkOfflineAccess(it.next(), Main.getJOSMWebsite());
779            } catch (OfflineAccessException ex) {
780                Logging.log(Logging.LEVEL_WARN, ex);
781                it.remove();
782            }
783        }
784        return pluginSites;
785    }
786
787    /**
788     * Sets the collection of plugin site URLs.
789     *
790     * @param sites the site URLs
791     */
792    public void setPluginSites(Collection<String> sites) {
793        putList("pluginmanager.sites", new ArrayList<>(sites));
794    }
795
796    /**
797     * Returns XML describing these preferences.
798     * @param nopass if password must be excluded
799     * @return XML
800     */
801    public String toXML(boolean nopass) {
802        return toXML(settingsMap.entrySet(), nopass, false);
803    }
804
805    /**
806     * Returns XML describing the given preferences.
807     * @param settings preferences settings
808     * @param nopass if password must be excluded
809     * @param defaults true, if default values are converted to XML, false for
810     * regular preferences
811     * @return XML
812     */
813    public String toXML(Collection<Entry<String, Setting<?>>> settings, boolean nopass, boolean defaults) {
814        try (
815            StringWriter sw = new StringWriter();
816            PreferencesWriter prefWriter = new PreferencesWriter(new PrintWriter(sw), nopass, defaults)
817        ) {
818            prefWriter.write(settings);
819            sw.flush();
820            return sw.toString();
821        } catch (IOException e) {
822            Logging.error(e);
823            return null;
824        }
825    }
826
827    /**
828     * Removes obsolete preference settings. If you throw out a once-used preference
829     * setting, add it to the list here with an expiry date (written as comment). If you
830     * see something with an expiry date in the past, remove it from the list.
831     * @param loadedVersion JOSM version when the preferences file was written
832     */
833    private void removeObsolete(int loadedVersion) {
834        for (String key : OBSOLETE_PREF_KEYS) {
835            if (settingsMap.containsKey(key)) {
836                settingsMap.remove(key);
837                Logging.info(tr("Preference setting {0} has been removed since it is no longer used.", key));
838            }
839        }
840    }
841
842    /**
843     * Enables or not the preferences file auto-save mechanism (save each time a setting is changed).
844     * This behaviour is enabled by default.
845     * @param enable if {@code true}, makes JOSM save preferences file each time a setting is changed
846     * @since 7085
847     */
848    public final void enableSaveOnPut(boolean enable) {
849        synchronized (this) {
850            saveOnPut = enable;
851        }
852    }
853}