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}