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