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}