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