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