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