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