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><node-identity>/profile.properties</code> preferences that 038 * are specific to the profile running on a specific host (<node-identity> 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}