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