001package jmri.profile; 002 003import java.beans.PropertyChangeEvent; 004import java.io.File; 005import java.io.FileInputStream; 006import java.io.FileNotFoundException; 007import java.io.FileOutputStream; 008import java.io.FileWriter; 009import java.io.IOException; 010import java.util.ArrayList; 011import java.util.Date; 012import java.util.List; 013import java.util.Objects; 014import java.util.Properties; 015import java.util.Set; 016import java.util.concurrent.ConcurrentHashMap; 017import java.util.concurrent.ThreadLocalRandom; 018import java.util.zip.ZipEntry; 019import java.util.zip.ZipOutputStream; 020 021import javax.annotation.CheckForNull; 022import javax.annotation.Nonnull; 023 024import org.jdom2.Document; 025import org.jdom2.Element; 026import org.jdom2.JDOMException; 027import org.jdom2.input.SAXBuilder; 028import org.jdom2.output.Format; 029import org.jdom2.output.XMLOutputter; 030import org.slf4j.Logger; 031import org.slf4j.LoggerFactory; 032import org.xml.sax.SAXParseException; 033 034import jmri.InstanceManager; 035import jmri.beans.Bean; 036import jmri.implementation.FileLocationsPreferences; 037import jmri.jmrit.roster.Roster; 038import jmri.jmrit.roster.RosterConfigManager; 039import jmri.util.FileUtil; 040import jmri.util.prefs.InitializationException; 041 042/** 043 * Manage JMRI configuration profiles. 044 * <p> 045 * This manager, and its configuration, fall outside the control of the 046 * {@link jmri.ConfigureManager} since the ConfigureManager's configuration is 047 * influenced by this manager. 048 * 049 * @author Randall Wood (C) 2014, 2015, 2016, 2019 050 */ 051public class ProfileManager extends Bean { 052 053 private final ArrayList<Profile> profiles = new ArrayList<>(); 054 private final ArrayList<File> searchPaths = new ArrayList<>(); 055 private Profile activeProfile = null; 056 private Profile nextActiveProfile = null; 057 private final File catalog; 058 private File configFile = null; 059 private boolean readingProfiles = false; 060 private boolean autoStartActiveProfile = false; 061 private File defaultSearchPath = new File(FileUtil.getPreferencesPath()); 062 private int autoStartActiveProfileTimeout = 10; 063 064 public static final String ACTIVE_PROFILE = "activeProfile"; // NOI18N 065 public static final String NEXT_PROFILE = "nextProfile"; // NOI18N 066 private static final String AUTO_START = "autoStart"; // NOI18N 067 private static final String AUTO_START_TIMEOUT = "autoStartTimeout"; // NOI18N 068 private static final String CATALOG = "profiles.xml"; // NOI18N 069 private static final String PROFILE = "profile"; // NOI18N 070 public static final String PROFILES = "profiles"; // NOI18N 071 private static final String PROFILECONFIG = "profileConfig"; // NOI18N 072 public static final String SEARCH_PATHS = "searchPaths"; // NOI18N 073 public static final String DEFAULT = "default"; // NOI18N 074 public static final String DEFAULT_SEARCH_PATH = "defaultSearchPath"; // NOI18N 075 public static final String SYSTEM_PROPERTY = "org.jmri.profile"; // NOI18N 076 private static final Logger log = LoggerFactory.getLogger(ProfileManager.class); 077 078 // Synchronized lazy initialization, default instance created the 1st time requested 079 private static class InstanceHolder { 080 private static final ProfileManager defaultInstance = new ProfileManager(); 081 } 082 083 /** 084 * Create a new ProfileManager using the default catalog. 085 */ 086 private ProfileManager() { 087 this.catalog = new File(FileUtil.getPreferencesPath() + CATALOG); 088 try { 089 this.readProfiles(); 090 this.findProfiles(); 091 } catch (JDOMException | IOException ex) { 092 log.error("Exception opening Profiles in {}", catalog, ex); 093 } 094 } 095 096 /** 097 * Get the default {@link ProfileManager}. 098 * <p> 099 * The default ProfileManager needs to be loaded before the InstanceManager 100 * since user interaction with the ProfileManager may change how the 101 * InstanceManager is configured. 102 * 103 * @return the default ProfileManager. 104 * @since 3.11.8 105 */ 106 @Nonnull 107 public static ProfileManager getDefault() { 108 return InstanceHolder.defaultInstance; 109 } 110 111 /** 112 * Get the {@link Profile} that is currently in use. 113 * <p> 114 * Note that this returning null is not an error condition, and should not 115 * be treated as such, since there are times when the user interacts with a 116 * JMRI application that there should be no active profile. 117 * 118 * @return the in use Profile or null if there is no Profile in use 119 */ 120 @CheckForNull 121 public Profile getActiveProfile() { 122 return activeProfile; 123 } 124 125 /** 126 * Get the name of the {@link Profile} that is currently in use. 127 * <p> 128 * This is a convenience method that avoids a need to check that 129 * {@link #getActiveProfile()} does not return null when all that is needed 130 * is the name of the active profile. 131 * 132 * @return the name of the active profile or null if there is no active 133 * profile 134 */ 135 @CheckForNull 136 public String getActiveProfileName() { 137 return activeProfile != null ? activeProfile.getName() : null; 138 } 139 140 /** 141 * Set the {@link Profile} to use. This method finds the Profile by path or 142 * Id and calls {@link #setActiveProfile(jmri.profile.Profile)}. 143 * 144 * @param identifier the profile path or id; can be null 145 */ 146 public void setActiveProfile(@CheckForNull String identifier) { 147 log.debug("setActiveProfile called with {}", identifier); 148 // handle null profile 149 if (identifier == null) { 150 Profile old = activeProfile; 151 activeProfile = null; 152 this.firePropertyChange(ProfileManager.ACTIVE_PROFILE, old, null); 153 log.debug("Setting active profile to null"); 154 return; 155 } 156 // handle profile path 157 File profileFile = new File(identifier); 158 File profileFileWithExt = new File(profileFile.getParent(), profileFile.getName() + Profile.EXTENSION); 159 if (Profile.isProfile(profileFileWithExt)) { 160 profileFile = profileFileWithExt; 161 } 162 log.debug("profileFile exists(): {}", profileFile.exists()); 163 log.debug("profileFile isDirectory(): {}", profileFile.isDirectory()); 164 if (profileFile.exists() && profileFile.isDirectory()) { 165 if (Profile.isProfile(profileFile)) { 166 try { 167 log.debug("try setActiveProfile with new Profile({})", profileFile); 168 this.setActiveProfile(new Profile(profileFile)); 169 log.debug(" success"); 170 return; 171 } catch (IOException ex) { 172 log.error("Unable to use profile path {} to set active profile.", identifier, ex); 173 } 174 } else { 175 log.error("{} is not a profile folder.", identifier); 176 } 177 } 178 // handle profile ID without path 179 for (Profile p : profiles) { 180 log.debug("Looking for profile {}, found {}", identifier, p.getId()); 181 if (p.getId().equals(identifier)) { 182 this.setActiveProfile(p); 183 return; 184 } 185 } 186 log.warn("Unable to set active profile. No profile with id {} could be found.", identifier); 187 } 188 189 /** 190 * Set the {@link Profile} to use. 191 * <p> 192 * Once the {@link jmri.ConfigureManager} is loaded, this only sets the 193 * Profile used at next application start. 194 * 195 * @param profile the profile to activate 196 */ 197 public void setActiveProfile(@CheckForNull Profile profile) { 198 Profile old = activeProfile; 199 if (profile == null) { 200 activeProfile = null; 201 this.firePropertyChange(ProfileManager.ACTIVE_PROFILE, old, null); 202 log.debug("Setting active profile to null"); 203 return; 204 } 205 activeProfile = profile; 206 this.firePropertyChange(ProfileManager.ACTIVE_PROFILE, old, profile); 207 log.debug("Setting active profile to {}", profile.getId()); 208 } 209 210 @CheckForNull 211 public Profile getNextActiveProfile() { 212 return this.nextActiveProfile; 213 } 214 215 protected void setNextActiveProfile(@CheckForNull Profile profile) { 216 Profile old = this.nextActiveProfile; 217 if (profile == null) { 218 this.nextActiveProfile = null; 219 this.firePropertyChange(ProfileManager.NEXT_PROFILE, old, null); 220 log.debug("Setting next active profile to null"); 221 return; 222 } 223 this.nextActiveProfile = profile; 224 this.firePropertyChange(ProfileManager.NEXT_PROFILE, old, profile); 225 log.debug("Setting next active profile to {}", profile.getId()); 226 } 227 228 /** 229 * Save the active {@link Profile} and automatic start setting. 230 * 231 * @throws java.io.IOException if unable to save the profile 232 */ 233 public void saveActiveProfile() throws IOException { 234 this.saveActiveProfile(this.getActiveProfile(), this.autoStartActiveProfile); 235 } 236 237 protected void saveActiveProfile(@CheckForNull Profile profile, boolean autoStart) throws IOException { 238 Properties p = new Properties(); 239 FileOutputStream os = null; 240 File config = this.getConfigFile(); 241 242 if (config == null) { 243 log.debug("No config file defined, not attempting to save active profile."); 244 return; 245 } 246 if (profile != null) { 247 p.setProperty(ACTIVE_PROFILE, profile.getId()); 248 p.setProperty(AUTO_START, Boolean.toString(autoStart)); 249 p.setProperty(AUTO_START_TIMEOUT, Integer.toString(this.getAutoStartActiveProfileTimeout())); 250 } 251 252 if (!config.exists() && !config.createNewFile()) { 253 throw new IOException("Unable to create file at " + config.getAbsolutePath()); // NOI18N 254 } 255 try { 256 os = new FileOutputStream(config); 257 p.storeToXML(os, "Active profile configuration (saved at " + (new Date()).toString() + ")"); // NOI18N 258 } catch (IOException ex) { 259 log.error("While trying to save active profile {}", config, ex); 260 throw ex; 261 } finally { 262 if (os != null) { 263 os.close(); 264 } 265 } 266 267 } 268 269 /** 270 * Read the active {@link Profile} and automatic start setting from the 271 * ProfileManager config file. 272 * 273 * @throws java.io.IOException if unable to read the profile 274 * @see #getConfigFile() 275 * @see #setConfigFile(java.io.File) 276 */ 277 public void readActiveProfile() throws IOException { 278 Properties p = new Properties(); 279 FileInputStream is = null; 280 File config = this.getConfigFile(); 281 if (config != null && config.exists() && config.length() != 0) { 282 try { 283 is = new FileInputStream(config); 284 try { 285 p.loadFromXML(is); 286 } catch (IOException ex) { 287 is.close(); 288 if (ex.getCause().getClass().equals(SAXParseException.class)) { 289 // try loading the profile as a standard properties file 290 is = new FileInputStream(config); 291 p.load(is); 292 } else { 293 throw ex; 294 } 295 } 296 is.close(); 297 } catch (IOException ex) { 298 if (is != null) { 299 is.close(); 300 } 301 throw ex; 302 } 303 this.setActiveProfile(p.getProperty(ACTIVE_PROFILE)); 304 if (p.containsKey(AUTO_START)) { 305 this.setAutoStartActiveProfile(Boolean.parseBoolean(p.getProperty(AUTO_START))); 306 } 307 if (p.containsKey(AUTO_START_TIMEOUT)) { 308 this.setAutoStartActiveProfileTimeout(Integer.parseInt(p.getProperty(AUTO_START_TIMEOUT))); 309 } 310 } 311 } 312 313 /** 314 * Get an array of enabled {@link Profile} objects. 315 * 316 * @return The enabled Profile objects 317 */ 318 @Nonnull 319 public Profile[] getProfiles() { 320 return profiles.toArray(new Profile[profiles.size()]); 321 } 322 323 /** 324 * Get an ArrayList of {@link Profile} objects. 325 * 326 * @return A list of all Profile objects 327 */ 328 @Nonnull 329 public List<Profile> getAllProfiles() { 330 return new ArrayList<>(profiles); 331 } 332 333 /** 334 * Get the enabled {@link Profile} at index. 335 * 336 * @param index the index of the desired Profile 337 * @return A Profile 338 */ 339 @CheckForNull 340 public Profile getProfiles(int index) { 341 if (index >= 0 && index < profiles.size()) { 342 return profiles.get(index); 343 } 344 return null; 345 } 346 347 /** 348 * Set the enabled {@link Profile} at index. 349 * 350 * @param profile the Profile to set 351 * @param index the index to set; any existing profile at index is removed 352 */ 353 public void setProfiles(Profile profile, int index) { 354 Profile oldProfile = profiles.get(index); 355 if (!this.readingProfiles) { 356 profiles.set(index, profile); 357 this.fireIndexedPropertyChange(PROFILES, index, oldProfile, profile); 358 } 359 } 360 361 protected void addProfile(Profile profile) { 362 if (!profiles.contains(profile)) { 363 profiles.add(profile); 364 if (!this.readingProfiles) { 365 profiles.sort(null); 366 int index = profiles.indexOf(profile); 367 this.fireIndexedPropertyChange(PROFILES, index, null, profile); 368 if (index != profiles.size() - 1) { 369 for (int i = index + 1; i < profiles.size() - 1; i++) { 370 this.fireIndexedPropertyChange(PROFILES, i, profiles.get(i + 1), profiles.get(i)); 371 } 372 this.fireIndexedPropertyChange(PROFILES, profiles.size() - 1, null, profiles.get(profiles.size() - 1)); 373 } 374 try { 375 this.writeProfiles(); 376 } catch (IOException ex) { 377 log.warn("Unable to write profiles while adding profile {}.", profile.getId(), ex); 378 } 379 } 380 } 381 } 382 383 protected void removeProfile(Profile profile) { 384 try { 385 int index = profiles.indexOf(profile); 386 if (index >= 0) { 387 if (profiles.remove(profile)) { 388 this.fireIndexedPropertyChange(PROFILES, index, profile, null); 389 this.writeProfiles(); 390 } 391 if (profile != null && profile.equals(this.getNextActiveProfile())) { 392 this.setNextActiveProfile(null); 393 this.saveActiveProfile(this.getActiveProfile(), this.autoStartActiveProfile); 394 } 395 } 396 } catch (IOException ex) { 397 log.warn("Unable to write profiles while removing profile {}.", profile.getId(), ex); 398 } 399 } 400 401 /** 402 * Get the paths that are searched for Profiles when presenting the user 403 * with a list of Profiles. Profiles that are discovered in these paths are 404 * automatically added to the catalog. 405 * 406 * @return Paths that may contain profiles 407 */ 408 @Nonnull 409 public File[] getSearchPaths() { 410 return searchPaths.toArray(new File[searchPaths.size()]); 411 } 412 413 public ArrayList<File> getAllSearchPaths() { 414 return this.searchPaths; 415 } 416 417 /** 418 * Get the search path at index. 419 * 420 * @param index the index of the search path 421 * @return A path that may contain profiles 422 */ 423 @CheckForNull 424 public File getSearchPaths(int index) { 425 if (index >= 0 && index < searchPaths.size()) { 426 return searchPaths.get(index); 427 } 428 return null; 429 } 430 431 protected void addSearchPath(@Nonnull File path) throws IOException { 432 if (!searchPaths.contains(path)) { 433 searchPaths.add(path); 434 if (!this.readingProfiles) { 435 int index = searchPaths.indexOf(path); 436 this.fireIndexedPropertyChange(SEARCH_PATHS, index, null, path); 437 this.writeProfiles(); 438 } 439 this.findProfiles(path); 440 } 441 } 442 443 protected void removeSearchPath(@Nonnull File path) throws IOException { 444 if (searchPaths.contains(path)) { 445 int index = searchPaths.indexOf(path); 446 searchPaths.remove(path); 447 this.fireIndexedPropertyChange(SEARCH_PATHS, index, path, null); 448 this.writeProfiles(); 449 if (this.getDefaultSearchPath().equals(path)) { 450 this.setDefaultSearchPath(new File(FileUtil.getPreferencesPath())); 451 } 452 } 453 } 454 455 @Nonnull 456 protected File getDefaultSearchPath() { 457 return this.defaultSearchPath; 458 } 459 460 protected void setDefaultSearchPath(@Nonnull File defaultSearchPath) throws IOException { 461 Objects.requireNonNull(defaultSearchPath); 462 if (!defaultSearchPath.equals(this.defaultSearchPath)) { 463 File oldDefault = this.defaultSearchPath; 464 this.defaultSearchPath = defaultSearchPath; 465 this.firePropertyChange(DEFAULT_SEARCH_PATH, oldDefault, this.defaultSearchPath); 466 this.writeProfiles(); 467 } 468 } 469 470 private void readProfiles() throws JDOMException, IOException { 471 try { 472 boolean reWrite = false; 473 if (!catalog.exists()) { 474 this.writeProfiles(); 475 } 476 if (!catalog.canRead()) { 477 return; 478 } 479 this.readingProfiles = true; 480 Document doc = (new SAXBuilder()).build(catalog); 481 profiles.clear(); 482 483 for (Element e : doc.getRootElement().getChild(PROFILES).getChildren()) { 484 File pp = FileUtil.getFile(null, e.getAttributeValue(Profile.PATH)); 485 try { 486 Profile p = new Profile(pp); 487 this.addProfile(p); 488 // update catalog if profile directory in catalog does not 489 // end in .jmri, but actual profile directory does 490 if (!p.getPath().equals(pp)) { 491 reWrite = true; 492 } 493 } catch (FileNotFoundException ex) { 494 log.info("Cataloged profile \"{}\" not in expected location\nSearching for it in {}", e.getAttributeValue(Profile.ID), pp.getParentFile()); 495 this.findProfiles(pp.getParentFile()); 496 reWrite = true; 497 } 498 } 499 searchPaths.clear(); 500 for (Element e : doc.getRootElement().getChild(SEARCH_PATHS).getChildren()) { 501 File path = FileUtil.getFile(null, e.getAttributeValue(Profile.PATH)); 502 if (!searchPaths.contains(path)) { 503 this.addSearchPath(path); 504 } 505 if (Boolean.parseBoolean(e.getAttributeValue(DEFAULT))) { 506 this.defaultSearchPath = path; 507 } 508 } 509 if (searchPaths.isEmpty()) { 510 this.addSearchPath(FileUtil.getFile(null, FileUtil.getPreferencesPath())); 511 } 512 this.readingProfiles = false; 513 if (reWrite) { 514 this.writeProfiles(); 515 } 516 this.profiles.sort(null); 517 } catch (JDOMException | IOException ex) { 518 this.readingProfiles = false; 519 throw ex; 520 } 521 } 522 523 private void writeProfiles() throws IOException { 524 if (!(new File(FileUtil.getPreferencesPath()).canWrite())) { 525 return; 526 } 527 FileWriter fw = null; 528 Document doc = new Document(); 529 doc.setRootElement(new Element(PROFILECONFIG)); 530 Element profilesElement = new Element(PROFILES); 531 Element pathsElement = new Element(SEARCH_PATHS); 532 this.profiles.stream().map((p) -> { 533 Element e = new Element(PROFILE); 534 e.setAttribute(Profile.ID, p.getId()); 535 e.setAttribute(Profile.PATH, FileUtil.getPortableFilename(null, p.getPath(), true, true)); 536 return e; 537 }).forEach((e) -> { 538 profilesElement.addContent(e); 539 }); 540 this.searchPaths.stream().map((f) -> { 541 Element e = new Element(Profile.PATH); 542 e.setAttribute(Profile.PATH, FileUtil.getPortableFilename(null, f.getPath(), true, true)); 543 e.setAttribute(DEFAULT, Boolean.toString(f.equals(this.defaultSearchPath))); 544 return e; 545 }).forEach((e) -> { 546 pathsElement.addContent(e); 547 }); 548 doc.getRootElement().addContent(profilesElement); 549 doc.getRootElement().addContent(pathsElement); 550 try { 551 fw = new FileWriter(catalog); 552 XMLOutputter fmt = new XMLOutputter(); 553 fmt.setFormat(Format.getPrettyFormat() 554 .setLineSeparator(System.getProperty("line.separator")) 555 .setTextMode(Format.TextMode.NORMALIZE)); 556 fmt.output(doc, fw); 557 fw.close(); 558 } catch (IOException ex) { 559 // close fw if possible 560 if (fw != null) { 561 fw.close(); 562 } 563 // rethrow the error 564 throw ex; 565 } 566 } 567 568 private void findProfiles() { 569 this.searchPaths.stream().forEach((searchPath) -> { 570 this.findProfiles(searchPath); 571 }); 572 } 573 574 private void findProfiles(@Nonnull File searchPath) { 575 File[] profilePaths = searchPath.listFiles((File pathname) -> Profile.isProfile(pathname)); 576 if (profilePaths == null) { 577 log.error("There was an error reading directory {}.", searchPath.getPath()); 578 return; 579 } 580 for (File pp : profilePaths) { 581 try { 582 Profile p = new Profile(pp); 583 this.addProfile(p); 584 } catch (IOException ex) { 585 log.error("Error attempting to read Profile at {}", pp, ex); 586 } 587 } 588 } 589 590 /** 591 * Get the file used to configure the ProfileManager. 592 * 593 * @return the appConfigFile 594 */ 595 @CheckForNull 596 public File getConfigFile() { 597 return configFile; 598 } 599 600 /** 601 * Set the file used to configure the ProfileManager. This is set on a 602 * per-application basis. 603 * 604 * @param configFile the appConfigFile to set 605 */ 606 public void setConfigFile(@Nonnull File configFile) { 607 this.configFile = configFile; 608 log.debug("Using config file {}", configFile); 609 } 610 611 /** 612 * Should the app automatically start with the active {@link Profile} 613 * without offering the user an opportunity to change the Profile? 614 * 615 * @return true if the app should start without user interaction 616 */ 617 public boolean isAutoStartActiveProfile() { 618 return (this.getActiveProfile() != null && autoStartActiveProfile); 619 } 620 621 /** 622 * Set if the app will next start without offering the user an opportunity 623 * to change the {@link Profile}. 624 * 625 * @param autoStartActiveProfile the autoStartActiveProfile to set 626 */ 627 public void setAutoStartActiveProfile(boolean autoStartActiveProfile) { 628 this.autoStartActiveProfile = autoStartActiveProfile; 629 } 630 631 /** 632 * Create a default profile if no profiles exist. 633 * 634 * @return A new profile or null if profiles already exist 635 * @throws IllegalArgumentException if profile already exists at default location 636 * @throws java.io.IOException if unable to create a Profile 637 */ 638 @CheckForNull 639 public Profile createDefaultProfile() throws IllegalArgumentException, IOException { 640 if (this.getAllProfiles().isEmpty()) { 641 String pn = Bundle.getMessage("defaultProfileName"); 642 String pid = FileUtil.sanitizeFilename(pn); 643 File pp = new File(FileUtil.getPreferencesPath() + pid + Profile.EXTENSION); 644 Profile profile = new Profile(pn, pid, pp); 645 this.addProfile(profile); 646 this.setAutoStartActiveProfile(true); 647 log.info("Created default profile \"{}\"", pn); 648 return profile; 649 } else { 650 return null; 651 } 652 } 653 654 /** 655 * Copy a JMRI configuration not in a profile and its user preferences to a 656 * profile. 657 * 658 * @param config the configuration file 659 * @param name the name of the configuration 660 * @return The profile with the migrated configuration 661 * @throws java.io.IOException if unable to create a Profile 662 * @throws IllegalArgumentException if profile already exists for config 663 */ 664 @Nonnull 665 public Profile migrateConfigToProfile(@Nonnull File config, @Nonnull String name) throws IllegalArgumentException, IOException { 666 String pid = FileUtil.sanitizeFilename(name); 667 File pp = new File(FileUtil.getPreferencesPath(), pid + Profile.EXTENSION); 668 Profile profile = new Profile(name, pid, pp); 669 FileUtil.copy(config, new File(profile.getPath(), Profile.CONFIG_FILENAME)); 670 FileUtil.copy(new File(config.getParentFile(), "UserPrefs" + config.getName()), new File(profile.getPath(), "UserPrefs" + Profile.CONFIG_FILENAME)); // NOI18N 671 this.addProfile(profile); 672 log.info("Migrated \"{}\" config to profile \"{}\"", name, name); 673 return profile; 674 } 675 676 /** 677 * Migrate a JMRI application to using {@link Profile}s. 678 * <p> 679 * Migration occurs when no profile configuration exists, but an application 680 * configuration exists. This method also handles the situation where an 681 * entirely new user is first starting JMRI, or where a user has deleted all 682 * their profiles. 683 * <p> 684 * When a JMRI application is starting there are eight potential 685 * Profile-related states requiring preparation to use profiles: 686 * <table> 687 * <caption>Matrix of states determining if migration required.</caption> 688 * <tr> 689 * <th>Profile Catalog</th> 690 * <th>Profile Config</th> 691 * <th>App Config</th> 692 * <th>Action</th> 693 * </tr> 694 * <tr> 695 * <td>YES</td> 696 * <td>YES</td> 697 * <td>YES</td> 698 * <td>No preparation required - migration from earlier JMRI complete</td> 699 * </tr> 700 * <tr> 701 * <td>YES</td> 702 * <td>YES</td> 703 * <td>NO</td> 704 * <td>No preparation required - JMRI installed after profiles feature 705 * introduced</td> 706 * </tr> 707 * <tr> 708 * <td>YES</td> 709 * <td>NO</td> 710 * <td>YES</td> 711 * <td>Migration required - other JMRI applications migrated to profiles by 712 * this user, but not this one</td> 713 * </tr> 714 * <tr> 715 * <td>YES</td> 716 * <td>NO</td> 717 * <td>NO</td> 718 * <td>No preparation required - prompt user for desired profile if multiple 719 * profiles exist, use default otherwise</td> 720 * </tr> 721 * <tr> 722 * <td>NO</td> 723 * <td>NO</td> 724 * <td>NO</td> 725 * <td>New user - create and use default profile</td> 726 * </tr> 727 * <tr> 728 * <td>NO</td> 729 * <td>NO</td> 730 * <td>YES</td> 731 * <td>Migration required - need to create first profile</td> 732 * </tr> 733 * <tr> 734 * <td>NO</td> 735 * <td>YES</td> 736 * <td>YES</td> 737 * <td>No preparation required - catalog will be automatically 738 * regenerated</td> 739 * </tr> 740 * <tr> 741 * <td>NO</td> 742 * <td>YES</td> 743 * <td>NO</td> 744 * <td>No preparation required - catalog will be automatically 745 * regenerated</td> 746 * </tr> 747 * </table> 748 * This method returns true if a migration occurred, and false in all other 749 * circumstances. 750 * 751 * @param configFilename the name of the app config file 752 * @return true if a user's existing config was migrated, false otherwise 753 * @throws java.io.IOException if unable to to create a Profile 754 * @throws IllegalArgumentException if profile already exists for configFilename 755 */ 756 public boolean migrateToProfiles(@Nonnull String configFilename) throws IllegalArgumentException, IOException { 757 File appConfigFile = new File(configFilename); 758 boolean didMigrate = false; 759 if (!appConfigFile.isAbsolute()) { 760 appConfigFile = new File(FileUtil.getPreferencesPath() + configFilename); 761 } 762 if (this.getAllProfiles().isEmpty()) { // no catalog and no profile config 763 if (!appConfigFile.exists()) { // no catalog and no profile config and no app config: new user 764 this.setActiveProfile(this.createDefaultProfile()); 765 this.saveActiveProfile(); 766 } else { // no catalog and no profile config, but an existing app config: migrate user who never used profiles before 767 this.setActiveProfile(this.migrateConfigToProfile(appConfigFile, jmri.Application.getApplicationName())); 768 this.saveActiveProfile(); 769 didMigrate = true; 770 } 771 } else if (appConfigFile.exists()) { // catalog and existing app config, but no profile config: migrate user who used profile with other JMRI app 772 try { 773 this.setActiveProfile(this.migrateConfigToProfile(appConfigFile, jmri.Application.getApplicationName())); 774 } catch (IllegalArgumentException ex) { 775 if (ex.getMessage().startsWith("A profile already exists at ")) { 776 // caused by attempt to migrate application with custom launcher 777 // strip ".xml" from configFilename name and use that to create profile 778 this.setActiveProfile(this.migrateConfigToProfile(appConfigFile, appConfigFile.getName().substring(0, appConfigFile.getName().length() - 4))); 779 } else { 780 // throw the exception so it can be dealt with, since other causes need user attention 781 // (most likely cause is a read-only settings directory) 782 throw ex; 783 } 784 } 785 this.saveActiveProfile(); 786 didMigrate = true; 787 } // all other cases need no prep 788 return didMigrate; 789 } 790 791 /** 792 * Export the {@link jmri.profile.Profile} to a zip file. 793 * 794 * @param profile The profile to export 795 * @param target The file to export the profile into 796 * @param exportExternalUserFiles If the User Files are not within the 797 * profile directory, should they be 798 * included? 799 * @param exportExternalRoster It the roster is not within the profile 800 * directory, should it be included? 801 * @throws java.io.IOException if unable to write a file during the 802 * export 803 * @throws org.jdom2.JDOMException if unable to create a new profile 804 * configuration file in the exported 805 * Profile 806 * @throws InitializationException if unable to read profile to export 807 */ 808 public void export(@Nonnull Profile profile, @Nonnull File target, boolean exportExternalUserFiles, 809 boolean exportExternalRoster) throws IOException, JDOMException, InitializationException { 810 if (!target.exists() && !target.createNewFile()) { 811 throw new IOException("Unable to create file " + target); 812 } 813 String tempDirPath = System.getProperty("java.io.tmpdir") + File.separator + "JMRI" + System.currentTimeMillis(); // NOI18N 814 FileUtil.createDirectory(tempDirPath); 815 File tempDir = new File(tempDirPath); 816 File tempProfilePath = new File(tempDir, profile.getPath().getName()); 817 FileUtil.copy(profile.getPath(), tempProfilePath); 818 Profile tempProfile = new Profile(tempProfilePath); 819 InstanceManager.getDefault(FileLocationsPreferences.class).initialize(profile); 820 InstanceManager.getDefault(FileLocationsPreferences.class).initialize(tempProfile); 821 InstanceManager.getDefault(RosterConfigManager.class).initialize(profile); 822 InstanceManager.getDefault(RosterConfigManager.class).initialize(tempProfile); 823 if (exportExternalUserFiles) { 824 FileUtil.copy(new File(FileUtil.getUserFilesPath(profile)), tempProfilePath); 825 FileUtil.setUserFilesPath(tempProfile, FileUtil.getProfilePath(tempProfile)); 826 InstanceManager.getDefault(FileLocationsPreferences.class).savePreferences(tempProfile); 827 } 828 if (exportExternalRoster) { 829 FileUtil.copy(new File(Roster.getRoster(profile).getRosterIndexPath()), new File(tempProfilePath, "roster.xml")); // NOI18N 830 FileUtil.copy(new File(Roster.getRoster(profile).getRosterLocation(), "roster"), new File(tempProfilePath, "roster")); // NOI18N 831 InstanceManager.getDefault(RosterConfigManager.class).setDirectory(profile, FileUtil.getPortableFilename(profile, tempProfilePath)); 832 InstanceManager.getDefault(RosterConfigManager.class).savePreferences(profile); 833 } 834 try (FileOutputStream out = new FileOutputStream(target); ZipOutputStream zip = new ZipOutputStream(out)) { 835 this.exportDirectory(zip, tempProfilePath, tempProfilePath.getPath()); 836 } 837 FileUtil.delete(tempDir); 838 } 839 840 private void exportDirectory(@Nonnull ZipOutputStream zip, @Nonnull File source, @Nonnull String root) throws IOException { 841 File[] files = source.listFiles(); 842 if (files != null) { 843 for (File file : files) { 844 if (file.isDirectory()) { 845 if (!Profile.isProfile(file)) { 846 ZipEntry entry = new ZipEntry(this.relativeName(file, root)); 847 entry.setTime(file.lastModified()); 848 zip.putNextEntry(entry); 849 this.exportDirectory(zip, file, root); 850 } 851 continue; 852 } 853 this.exportFile(zip, file, root); 854 } 855 } 856 } 857 858 private void exportFile(@Nonnull ZipOutputStream zip, @Nonnull File source, @Nonnull String root) throws IOException { 859 byte[] buffer = new byte[1024]; 860 int length; 861 862 try (FileInputStream input = new FileInputStream(source)) { 863 ZipEntry entry = new ZipEntry(this.relativeName(source, root)); 864 entry.setTime(source.lastModified()); 865 zip.putNextEntry(entry); 866 while ((length = input.read(buffer)) > 0) { 867 zip.write(buffer, 0, length); 868 } 869 zip.closeEntry(); 870 } 871 } 872 873 @Nonnull 874 private String relativeName(@Nonnull File file, @Nonnull String root) { 875 String path = file.getPath(); 876 if (path.startsWith(root)) { 877 path = path.substring(root.length()); 878 } 879 if (file.isDirectory() && !path.endsWith("/")) { 880 path = path + "/"; 881 } 882 return path.replace(File.separator, "/"); 883 } 884 885 /** 886 * Get the active profile. 887 * <p> 888 * This method initiates the process of setting the active profile when a 889 * headless app launches. 890 * 891 * @return The active {@link Profile} 892 * @throws java.io.IOException if unable to read the current active profile 893 * @see ProfileManagerDialog#getStartingProfile(java.awt.Frame) 894 */ 895 @CheckForNull 896 public static Profile getStartingProfile() throws IOException { 897 if (ProfileManager.getDefault().getActiveProfile() == null) { 898 ProfileManager.getDefault().readActiveProfile(); 899 // Automatically start with only profile if only one profile 900 if (ProfileManager.getDefault().getProfiles().length == 1) { 901 ProfileManager.getDefault().setActiveProfile(ProfileManager.getDefault().getProfiles(0)); 902 // Display profile selector if user did not choose to auto start with last used profile 903 } else if (!ProfileManager.getDefault().isAutoStartActiveProfile()) { 904 return null; 905 } 906 } 907 return ProfileManager.getDefault().getActiveProfile(); 908 } 909 910 /** 911 * Ids already returned by {@link #createUniqueId()}, so that it never 912 * returns the same id twice in one run of JMRI. 913 */ 914 private static final Set<String> CREATED_IDS = ConcurrentHashMap.newKeySet(); 915 916 /** 917 * Generate a pseudorandom id that is unique among the ids generated by 918 * this method since JMRI started. 919 * <p> 920 * This can be used to generate the id for a 921 * {@link jmri.profile.NullProfile}. Implementing applications should save 922 * this value so that the id of a NullProfile is consistent across 923 * application launches. 924 * <p> 925 * Profiles with the same id are treated as the same profile (see 926 * {@link Profile#equals(Object)}), so two new profiles must never be given 927 * the same id. 928 * 929 * @return eight lower case hexadecimal digits. 930 */ 931 @Nonnull 932 public static String createUniqueId() { 933 String id; 934 do { 935 id = String.format("%08x", ThreadLocalRandom.current().nextInt()); 936 } while (!CREATED_IDS.add(id)); 937 return id; 938 } 939 940 void profileNameChange(Profile profile, String oldName) { 941 this.firePropertyChange(new PropertyChangeEvent(profile, Profile.NAME, oldName, profile.getName())); 942 } 943 944 /** 945 * Seconds to display profile selector before automatically starting. 946 * <p> 947 * If 0, selector will not automatically dismiss. 948 * 949 * @return Seconds to display selector. 950 */ 951 public int getAutoStartActiveProfileTimeout() { 952 return this.autoStartActiveProfileTimeout; 953 } 954 955 /** 956 * Set the number of seconds to display the profile selector before 957 * automatically starting. 958 * <p> 959 * If negative or greater than 300 (5 minutes), set to 0 to prevent 960 * automatically starting with any profile. 961 * <p> 962 * Call {@link #saveActiveProfile() } after setting this to persist the 963 * value across application restarts. 964 * 965 * @param autoStartActiveProfileTimeout Seconds to display profile selector 966 */ 967 public void setAutoStartActiveProfileTimeout(int autoStartActiveProfileTimeout) { 968 int old = this.autoStartActiveProfileTimeout; 969 if (autoStartActiveProfileTimeout < 0 || autoStartActiveProfileTimeout > 500) { 970 autoStartActiveProfileTimeout = 0; 971 } 972 if (old != autoStartActiveProfileTimeout) { 973 this.autoStartActiveProfileTimeout = autoStartActiveProfileTimeout; 974 this.firePropertyChange(AUTO_START_TIMEOUT, old, this.autoStartActiveProfileTimeout); 975 } 976 } 977}