001package jmri.util.prefs;
002
003import java.io.File;
004import java.io.FileInputStream;
005import java.io.FileOutputStream;
006import java.io.IOException;
007import java.util.ArrayList;
008import java.util.Enumeration;
009import java.util.HashMap;
010import java.util.List;
011import java.util.Map;
012import java.util.Properties;
013import java.util.TreeMap;
014import java.util.prefs.AbstractPreferences;
015import java.util.prefs.BackingStoreException;
016import java.util.prefs.Preferences;
017import javax.annotation.Nonnull;
018import javax.annotation.CheckForNull;
019import jmri.Version;
020import jmri.profile.Profile;
021import jmri.profile.ProfileUtils;
022import jmri.util.FileUtil;
023import jmri.util.OrderedProperties;
024import jmri.util.node.NodeIdentity;
025
026/**
027 * Provides instances of {@link java.util.prefs.Preferences} backed by a
028 * JMRI-specific storage implementation based on a Properties file.
029 * <p>
030 * There are two Properties files per {@link jmri.profile.Profile} and
031 * {@link jmri.util.node.NodeIdentity}, both stored in the directory
032 * <code>profile:profile</code>:
033 * <ul>
034 * <li><code>profile.properties</code> preferences that are shared across
035 * multiple nodes for a single profile. An example of such a preference would be
036 * the Railroad Name preference.</li>
037 * <li><code>&lt;node-identity&gt;/profile.properties</code> preferences that
038 * are specific to the profile running on a specific host (&lt;node-identity&gt;
039 * is the identity returned by
040 * {@link jmri.util.node.NodeIdentity#storageIdentity()}). An example of such a
041 * preference would be a file location.</li>
042 * </ul>
043 * <p>
044 * Non-profile specific configuration that applies to all profiles is stored in
045 * the file <code>settings:preferences/preferences.properties</code>.
046 *
047 * @author Randall Wood 2015
048 */
049public final class JmriPreferencesProvider {
050
051    private final JmriPreferences root;
052    private final File path;
053    private final boolean firstUse;
054    private final boolean shared;
055    private boolean backedUp = false;
056
057    private static final HashMap<File, JmriPreferencesProvider> SHARED_PROVIDERS = new HashMap<>();
058    private static final HashMap<File, JmriPreferencesProvider> PRIVATE_PROVIDERS = new HashMap<>();
059
060    /**
061     * Get the JmriPreferencesProvider for the specified profile path.
062     *
063     * @param path   The root path of a {@link jmri.profile.Profile}. This is
064     *               most frequently the path returned by
065     *               {@link jmri.profile.Profile#getPath()}.
066     * @param shared True if the preferences apply to the profile at path
067     *               regardless of host. If false, the preferences only apply to
068     *               this computer.
069     * @return The shared or private JmriPreferencesProvider for the project at
070     *         path.
071     */
072    @Nonnull
073    static synchronized JmriPreferencesProvider findProvider(@CheckForNull File path, boolean shared) {
074        if (shared) {
075            return SHARED_PROVIDERS.computeIfAbsent(path, v -> new JmriPreferencesProvider(path, shared));
076        } else {
077            return PRIVATE_PROVIDERS.computeIfAbsent(path, v -> new JmriPreferencesProvider(path, shared));
078        }
079    }
080
081    /**
082     * Get the {@link java.util.prefs.Preferences} for the specified class in
083     * the specified profile.
084     *
085     * @param project The profile. This is most often the profile returned by
086     *                the {@link jmri.profile.ProfileManager#getActiveProfile()}
087     *                method of the ProfileManager returned by
088     *                {@link jmri.profile.ProfileManager#getDefault()}. If null,
089     *                preferences apply to all profiles on the computer and the
090     *                value of shared is ignored.
091     * @param clazz   The class requesting preferences. Note that the
092     *                preferences returned are for the package containing the
093     *                class.
094     * @param shared  True if the preferences apply to this profile regardless
095     *                of host. If false, the preferences only apply to this
096     *                computer. Ignored if the value of project is null.
097     * @return The shared or private Preferences node for the package containing
098     *         clazz for project.
099     */
100    @Nonnull
101    public static Preferences getPreferences(@CheckForNull final Profile project, @CheckForNull final Class<?> clazz,
102            final boolean shared) {
103        return getPreferences(project, clazz != null ? clazz.getPackage() : null, shared);
104    }
105
106    /**
107     * Get the {@link java.util.prefs.Preferences} for the specified package in
108     * the specified profile.
109     *
110     * @param project The profile. This is most often the profile returned by
111     *                the {@link jmri.profile.ProfileManager#getActiveProfile()}
112     *                method of the ProfileManager returned by
113     *                {@link jmri.profile.ProfileManager#getDefault()}. If null,
114     *                preferences apply to all profiles on the computer and the
115     *                value of shared is ignored.
116     * @param pkg     The package requesting preferences.
117     * @param shared  True if the preferences apply to this profile regardless
118     *                of host. If false, the preferences only apply to this
119     *                computer. Ignored if the value of project is null.
120     * @return The shared or private Preferences node for the package for
121     *         project.
122     */
123    @Nonnull
124    public static Preferences getPreferences(@CheckForNull final Profile project, @CheckForNull final Package pkg,
125            final boolean shared) {
126        if (project != null) {
127            return findProvider(project.getPath(), shared).getPreferences(pkg);
128        } else {
129            return findProvider(null, shared).getPreferences(pkg);
130        }
131    }
132
133    /**
134     * Get the {@link java.util.prefs.Preferences} for the specified package in
135     * the specified profile.
136     * <P>
137     * Use of
138     *  {@link #getPreferences(Profile, Class, boolean)} or
139     *  {@link #getPreferences(Profile, Package, boolean)} is
140     *   preferred and recommended unless reading preferences for a
141     *   non-existent package or class.
142     *
143     * @param project The profile. This is most often the profile returned by
144     *                the {@link jmri.profile.ProfileManager#getActiveProfile()}
145     *                method of the ProfileManager returned by
146     *                {@link jmri.profile.ProfileManager#getDefault()}. If null,
147     *                preferences apply to all profiles on the computer and the
148     *                value of shared is ignored.
149     * @param pkg     The package requesting preferences.
150     * @param shared  True if the preferences apply to this profile regardless
151     *                of host. If false, the preferences only apply to this
152     *                computer. Ignored if the value of project is null.
153     * @return The shared or private Preferences node for the package.
154     */
155    @Nonnull
156    public static Preferences getPreferences(@CheckForNull final Profile project, @CheckForNull final String pkg,
157            final boolean shared) {
158        if (project != null) {
159            return findProvider(project.getPath(), shared).getPreferences(pkg);
160        } else {
161            return findProvider(null, shared).getPreferences(pkg);
162        }
163    }
164
165    /**
166     * Get the {@link java.util.prefs.Preferences} for the specified class in
167     * the specified path.
168     * <P>
169     * Use of
170     *   {@link #getPreferences(jmri.profile.Profile, java.lang.Class, boolean)}
171     *    is preferred and recommended unless being used to during the
172     *    construction of a Profile object.
173     *
174     * @param path   The path to a profile. This is most often the result of
175     *               {@link jmri.profile.Profile#getPath()} for a given Profile.
176     *               If null, preferences apply to all profiles on the computer
177     *               and the value of shared is ignored.
178     * @param clazz  The class requesting preferences. Note that the preferences
179     *               returned are for the package containing the class.
180     * @param shared True if the preferences apply to this profile regardless of
181     *               host. If false, the preferences only apply to this
182     *               computer. Ignored if the value of path is null.
183     * @return The shared or private Preferences node for the package containing
184     *         clazz for project.
185     */
186    public static Preferences getPreferences(@CheckForNull final File path, @CheckForNull final Class<?> clazz,
187            final boolean shared) {
188        return findProvider(path, shared).getPreferences(clazz);
189    }
190
191    /**
192     * Get the {@link java.util.prefs.Preferences} for the specified package.
193     *
194     * @param pkg The package requesting preferences.
195     * @return The shared or private Preferences node for the package.
196     */
197    // package private
198    Preferences getPreferences(@CheckForNull final Package pkg) {
199        if (pkg == null) {
200            return this.root;
201        }
202        return this.root.node(findCNBForPackage(pkg));
203    }
204
205    /**
206     * Get the {@link java.util.prefs.Preferences} for the specified class.
207     *
208     * @param clazz The class requesting preferences. Note that the preferences
209     *              returned are for the package containing the class.
210     * @return The shared or private Preferences node for the package containing
211     *         clazz.
212     */
213    // package private
214    Preferences getPreferences(@CheckForNull final Class<?> clazz) {
215        return getPreferences(clazz != null ? clazz.getPackage() : null);
216    }
217
218    /**
219     * Get the {@link java.util.prefs.Preferences} for the specified package.
220     *
221     * @param pkg The package for which preferences are needed.
222     * @return The shared or private Preferences node for the package.
223     */
224    // package private
225    Preferences getPreferences(@CheckForNull final String pkg) {
226        if (pkg == null) {
227            return this.root;
228        }
229        return this.root.node(pkg);
230    }
231
232    JmriPreferencesProvider(@CheckForNull File path, boolean shared) {
233        this.path = path;
234        this.shared = shared;
235        this.firstUse = !this.getPreferencesFile().exists();
236        this.root = new JmriPreferences(null, "");
237        if (!this.firstUse) {
238            try {
239                this.root.sync();
240            } catch (BackingStoreException ex) {
241                log.error("Unable to read preferences", ex);
242            }
243        }
244    }
245
246    /**
247     * Return true if the properties file had not been written for a JMRI
248     * {@link jmri.profile.Profile} before this instance of JMRI was launched.
249     * Note that the first use of a node-specific setting can be different than
250     * the first use of a multi-node setting.
251     *
252     * @return true if new or newly migrated profile, false otherwise
253     */
254    public boolean isFirstUse() {
255        return this.firstUse;
256    }
257
258    /**
259     * Returns the name of the package for the class in a format that is treated
260     * as a single token.
261     *
262     * @param cls The class for which a sanitized package name is needed
263     * @return A sanitized package name
264     */
265    public static String findCNBForClass(@Nonnull Class<?> cls) {
266        return findCNBForPackage(cls.getPackage());
267    }
268
269    /**
270     * Returns the name of the package in a format that is treated as a single
271     * token.
272     *
273     * @param pkg The package for which a sanitized name is needed
274     * @return A sanitized package name
275     */
276    public static String findCNBForPackage(@Nonnull Package pkg) {
277        return pkg.getName().replace('.', '-');
278    }
279
280    @Nonnull
281    File getPreferencesFile() {
282        if (this.path == null) {
283            return new File(this.getPreferencesDirectory(), "preferences.properties");
284        } else {
285            return new File(this.getPreferencesDirectory(), Profile.PROPERTIES);
286        }
287    }
288
289    @Nonnull
290    private File getPreferencesDirectory() {
291        File dir;
292        if (this.path == null) {
293            dir = new File(FileUtil.getPreferencesPath(), "preferences");
294        } else {
295            dir = new File(this.path, Profile.PROFILE);
296            if (!this.shared) {
297                // protect against testing a new profile
298                if (Profile.isProfile(this.path)) {
299                    try {
300                        Profile profile = new Profile(this.path);
301                        File nodeDir = new File(dir, NodeIdentity.storageIdentity(profile));
302                        if (!nodeDir.exists() && !ProfileUtils.copyPrivateContentToCurrentIdentity(profile)) {
303                            log.debug("Starting profile with new private preferences.");
304                        }
305                    } catch (IOException ex) {
306                        log.debug("Copying existing private configuration failed.");
307                    }
308                }
309                dir = new File(dir, NodeIdentity.storageIdentity());
310            }
311        }
312        FileUtil.createDirectory(dir);
313        return dir;
314    }
315
316    /**
317     * @return the backedUp
318     */
319    protected boolean isBackedUp() {
320        return backedUp;
321    }
322
323    /**
324     * @param backedUp the backedUp to set
325     */
326    protected void setBackedUp(boolean backedUp) {
327        this.backedUp = backedUp;
328    }
329
330    private class JmriPreferences extends AbstractPreferences {
331
332        private final Map<String, String> theRoot;
333        private final Map<String, JmriPreferences> children;
334        private boolean isRemoved = false;
335
336        public JmriPreferences(AbstractPreferences parent, String name) {
337            super(parent, name);
338
339            log.trace("Instantiating node \"{}\"", name);
340
341            theRoot = new TreeMap<>();
342            children = new TreeMap<>();
343
344            try {
345                super.sync();
346            } catch (BackingStoreException e) {
347                log.error("Unable to sync on creation of node {}", name, e);
348            }
349        }
350
351        @Override
352        protected void putSpi(String key, String value) {
353            theRoot.put(key, value);
354            try {
355                flush();
356            } catch (BackingStoreException e) {
357                log.error("Unable to flush after putting {}", key, e);
358            }
359        }
360
361        @Override
362        protected String getSpi(String key) {
363            return theRoot.get(key);
364        }
365
366        @Override
367        protected void removeSpi(String key) {
368            theRoot.remove(key);
369            try {
370                flush();
371            } catch (BackingStoreException e) {
372                log.error("Unable to flush after removing {}", key, e);
373            }
374        }
375
376        @Override
377        protected void removeNodeSpi() throws BackingStoreException {
378            isRemoved = true;
379            flush();
380        }
381
382        @Override
383        protected String[] keysSpi() throws BackingStoreException {
384            return theRoot.keySet().toArray(new String[theRoot.keySet().size()]);
385        }
386
387        @Override
388        protected String[] childrenNamesSpi() throws BackingStoreException {
389            return children.keySet().toArray(new String[children.keySet().size()]);
390        }
391
392        @Override
393        protected JmriPreferences childSpi(String name) {
394            JmriPreferences child = children.get(name);
395            if (child == null || child.isRemoved()) {
396                child = new JmriPreferences(this, name);
397                children.put(name, child);
398            }
399            return child;
400        }
401
402        @Override
403        protected void syncSpi() throws BackingStoreException {
404            if (isRemoved()) {
405                return;
406            }
407
408            final File file = JmriPreferencesProvider.this.getPreferencesFile();
409
410            if (!file.exists()) {
411                return;
412            }
413
414            synchronized (file) {
415                Properties p = new OrderedProperties();
416                try {
417                    try (FileInputStream fis = new FileInputStream(file)) {
418                        p.load(fis);
419                    }
420
421                    StringBuilder sb = new StringBuilder();
422                    getPath(sb);
423                    String pp = sb.toString();
424
425                    final Enumeration<?> pnen = p.propertyNames();
426                    while (pnen.hasMoreElements()) {
427                        String propKey = (String) pnen.nextElement();
428                        if (propKey.startsWith(pp)) {
429                            String subKey = propKey.substring(pp.length());
430                            // Only load immediate descendants
431                            if (subKey.indexOf('.') == -1) {
432                                theRoot.put(subKey, p.getProperty(propKey));
433                            }
434                        }
435                    }
436                } catch (IOException e) {
437                    throw new BackingStoreException(e);
438                }
439            }
440        }
441
442        private void getPath(StringBuilder sb) {
443            final JmriPreferences parent = (JmriPreferences) parent();
444            if (parent == null) {
445                return;
446            }
447
448            parent.getPath(sb);
449            sb.append(name()).append('.');
450        }
451
452        @Override
453        protected void flushSpi() throws BackingStoreException {
454            final File file = JmriPreferencesProvider.this.getPreferencesFile();
455
456            synchronized (file) {
457                Properties p = new OrderedProperties();
458                try {
459
460                    StringBuilder sb = new StringBuilder();
461                    getPath(sb);
462                    String pp = sb.toString();
463
464                    if (file.exists()) {
465                        try (FileInputStream fis = new FileInputStream(file)) {
466                            p.load(fis);
467                        }
468
469                        List<String> toRemove = new ArrayList<>();
470
471                        // Make a list of all direct children of this node to be
472                        // removed
473                        final Enumeration<?> pnen = p.propertyNames();
474                        while (pnen.hasMoreElements()) {
475                            String propKey = (String) pnen.nextElement();
476                            if (propKey.startsWith(pp)) {
477                                String subKey = propKey.substring(pp.length());
478                                // Only do immediate descendants
479                                if (subKey.indexOf('.') == -1) {
480                                    toRemove.add(propKey);
481                                }
482                            }
483                        }
484
485                        // Remove them now that the enumeration is done with
486                        toRemove.stream().forEach(p::remove);
487                    }
488
489                    // If this node hasn't been removed, add back in any values
490                    if (!isRemoved) {
491                        theRoot.keySet().stream().forEach(s -> p.setProperty(pp + s, theRoot.get(s)));
492                    }
493
494                    if (!JmriPreferencesProvider.this.isBackedUp() && file.exists()) {
495                        log.debug("Backing up {}", file);
496                        FileUtil.backup(file);
497                        JmriPreferencesProvider.this.setBackedUp(true);
498                    }
499                    try (FileOutputStream fos = new FileOutputStream(file)) {
500                        p.store(fos, "JMRI Preferences version " + Version.name());
501                    }
502                } catch (IOException e) {
503                    throw new BackingStoreException(e);
504                }
505            }
506        }
507
508        @SuppressWarnings("hiding")     // Field has same name as a field in the outer class
509        private final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(JmriPreferences.class);
510    }
511
512    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(JmriPreferencesProvider.class);
513}