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