001package jmri.profile;
002
003import java.io.File;
004import java.io.IOException;
005
006import javax.annotation.Nonnull;
007
008/**
009 * A JMRI application profile. Profiles allow a JMRI application to load
010 * completely separate set of preferences at each launch without relying on host
011 * OS-specific tricks to ensure this happens.
012 *
013 * It is recommended that profile directory names end in {@value #EXTENSION} so
014 * that supporting iOS and macOS applications could potentially treat a JMRI
015 * profile as a single file, instead of as a directory structure. This would
016 * allow an application subject to mandatory security controls in iOS, and an
017 * application sandbox on macOS to request permission from the user to access
018 * the entire profile once, instead of needing to request permission to access
019 * each file individually. This would also allow a profile to be opened by
020 * double clicking on it, and to have a unique icon within the iOS Files app and
021 * macOS Finder.
022 * 
023 * Note that JMRI itself is not currently capable of supporting opening a
024 * profile by double clicking on it, even if other applications on the same
025 * computer can.
026 * 
027 * @author Randall Wood Copyright (C) 2013, 2014, 2015, 2018
028 */
029public class Profile implements Comparable<Profile> {
030
031    private String name;
032    private String id;
033    private File path;
034    public static final String PROFILE = "profile"; // NOI18N
035    protected static final String ID = "id"; // NOI18N
036    protected static final String NAME = "name"; // NOI18N
037    protected static final String PATH = "path"; // NOI18N
038    public static final String PROPERTIES = "profile.properties"; // NOI18N
039    public static final String CONFIG = "profile.xml"; // NOI18N
040    public static final String SHARED_PROPERTIES = PROFILE + "/" + PROPERTIES; // NOI18N
041    public static final String SHARED_CONFIG = PROFILE + "/" + CONFIG; // NOI18N
042    /**
043     * {@value #CONFIG_FILENAME} may be present in older profiles
044     */
045    public static final String CONFIG_FILENAME = "ProfileConfig.xml"; // NOI18N
046    public static final String UI_CONFIG = "user-interface.xml"; // NOI18N
047    public static final String SHARED_UI_CONFIG = PROFILE + "/" + UI_CONFIG; // NOI18N
048    /**
049     * {@value #UI_CONFIG_FILENAME} may be present in older profiles
050     */
051    public static final String UI_CONFIG_FILENAME = "UserPrefsProfileConfig.xml"; // NOI18N
052    /**
053     * The filename extension for JMRI profile directories. This is needed for
054     * external applications on some operating systems to recognize JMRI
055     * profiles.
056     */
057    public static final String EXTENSION = ".jmri"; // NOI18N
058
059    /**
060     * Create a Profile object given just a path to it. The Profile must exist
061     * in storage on the computer.
062     *
063     * @param path The Profile's directory
064     * @throws java.io.IOException If unable to read the Profile from path
065     */
066    public Profile(@Nonnull File path) throws IOException {
067        this(path, true);
068    }
069
070    /**
071     * Create a Profile object and a profile in storage. A Profile cannot exist
072     * in storage on the computer at the path given. Since this is a new
073     * profile, the id must match the last element in the path.
074     * <p>
075     * This is the only time the id can be set on a Profile, as the id becomes a
076     * read-only property of the Profile. The {@link ProfileManager} will only
077     * load a single profile with a given id.
078     *
079     * @param name Name of the profile. Will not be used to enforce uniqueness
080     *             constraints.
081     * @param id   Id of the profile. Will be prepended to a random String to
082     *             enforce uniqueness constraints.
083     * @param path Location to store the profile; {@value #EXTENSION} will be
084     *             appended to this path if needed.
085     * @throws java.io.IOException If unable to create the profile at path
086     * @throws IllegalArgumentException If a profile already exists at or within path
087     */
088    public Profile(@Nonnull String name, @Nonnull String id, @Nonnull File path) throws IOException {
089        File pathWithExt; // path with extension
090        if (path.getName().endsWith(EXTENSION)) {
091            pathWithExt = path;
092        } else {
093            pathWithExt = new File(path.getParentFile(), path.getName() + EXTENSION);
094        }
095        if (!pathWithExt.getName().equals(id + EXTENSION)) {
096            throw new IllegalArgumentException(id + " " + path.getName() + " do not match"); // NOI18N
097        }
098        if (Profile.isProfile(path) || Profile.isProfile(pathWithExt)) {
099            throw new IllegalArgumentException("A profile already exists at " + path); // NOI18N
100        }
101        if (Profile.containsProfile(path) || Profile.containsProfile(pathWithExt)) {
102            throw new IllegalArgumentException(path + " contains a profile in a subdirectory."); // NOI18N
103        }
104        if (Profile.inProfile(path) || Profile.inProfile(pathWithExt)) {
105            if (Profile.inProfile(path)) log.warn("Exception: Path {} is within an existing profile.", path, new Exception("traceback")); // NOI18N
106            if (Profile.inProfile(pathWithExt)) log.warn("Exception: pathWithExt {} is within an existing profile.", pathWithExt, new Exception("traceback")); // NOI18N
107            throw new IllegalArgumentException(path + " is within an existing profile."); // NOI18N
108        }
109        this.name = name;
110        this.id = id + "." + ProfileManager.createUniqueId();
111        this.path = pathWithExt;
112        // use field, not local variables (path or pathWithExt) for paths below
113        if (!this.path.exists() && !this.path.mkdirs()) {
114            throw new IOException("Unable to create directory " + this.path); // NOI18N
115        }
116        if (!this.path.isDirectory()) {
117            throw new IllegalArgumentException(path + " is not a directory"); // NOI18N
118        }
119        this.save();
120        if (!Profile.isProfile(this.path)) {
121            throw new IllegalArgumentException(path + " does not contain a profile.properties file"); // NOI18N
122        }
123    }
124
125    /**
126     * Create a Profile object given just a path to it. If isReadable is true,
127     * the Profile must exist in storage on the computer. Generates a random id
128     * for the profile.
129     * <p>
130     * This method exists purely to support subclasses.
131     *
132     * @param path       The Profile's directory
133     * @param isReadable True if the profile has storage. See
134     *                   {@link jmri.profile.NullProfile} for a Profile subclass
135     *                   where this is not true.
136     * @throws java.io.IOException If the profile's preferences cannot be read.
137     */
138    protected Profile(@Nonnull File path, boolean isReadable) throws IOException {
139        this(path, ProfileManager.createUniqueId(), isReadable);
140    }
141
142    /**
143     * Create a Profile object given just a path to it. If isReadable is true,
144     * the Profile must exist in storage on the computer.
145     * <p>
146     * This method exists purely to support subclasses.
147     *
148     * @param path       The Profile's directory
149     * @param id         The Profile's id
150     * @param isReadable True if the profile has storage. See
151     *                   {@link jmri.profile.NullProfile} for a Profile subclass
152     *                   where this is not true.
153     * @throws java.io.IOException If the profile's preferences cannot be read.
154     */
155    protected Profile(@Nonnull File path, @Nonnull String id, boolean isReadable) throws IOException {
156        File pathWithExt; // path with extension
157        if (path.getName().endsWith(EXTENSION)) {
158            pathWithExt = path;
159        } else {
160            pathWithExt = new File(path.getParentFile(), path.getName() + EXTENSION);
161        }
162        // if path does not exist, but pathWithExt exists, use pathWithExt
163        // to support a scenario where user adds .jmri extension to profile
164        // directory outside of JMRI application
165        if ((!path.exists() && pathWithExt.exists())) {
166            this.path = pathWithExt;
167        } else {
168            this.path = path;
169        }
170        this.id = id;
171        if (isReadable) {
172            this.readProfile();
173        }
174    }
175
176    protected final void save() {
177        ProfileProperties p = new ProfileProperties(this);
178        p.put(NAME, this.name, true);
179        p.put(ID, this.id, true);
180    }
181
182    /**
183     * @return the name
184     */
185    public String getName() {
186        return name;
187    }
188
189    /**
190     * Set the name for this profile.
191     * <p>
192     * Overriding classing must use
193     * {@link #setNameInConstructor(java.lang.String)} to set the name in a
194     * constructor since this method passes this Profile object to an object
195     * excepting a completely constructed Profile.
196     *
197     * @param name the new name
198     */
199    public void setName(String name) {
200        String oldName = this.name;
201        this.name = name;
202        ProfileManager.getDefault().profileNameChange(this, oldName);
203    }
204
205    /**
206     * Set the name for this profile while constructing the profile.
207     * <p>
208     * Overriding classing must use this method to set the name in a constructor
209     * since {@link #setName(java.lang.String)} passes this Profile object to an
210     * object expecting a completely constructed Profile.
211     *
212     * @param name the new name
213     */
214    protected final void setNameInConstructor(String name) {
215        this.name = name;
216    }
217
218    /**
219     * @return the id
220     */
221    @Nonnull
222    public String getId() {
223        return id;
224    }
225
226    /**
227     * @return the path
228     */
229    public File getPath() {
230        return path;
231    }
232
233    private void readProfile() {
234        ProfileProperties p = new ProfileProperties(path);
235        String readId = p.get(ID, true);
236        if (readId != null) {
237            id = readId;
238        }
239        String readName = p.get(NAME, true);
240        if (readName != null) {
241            name = readName;
242        }
243    }
244
245    @Override
246    public String toString() {
247        return this.getName();
248    }
249
250    @Override
251    public int hashCode() {
252        int hash = 7;
253        hash = 71 * hash + (this.id != null ? this.id.hashCode() : 0);
254        return hash;
255    }
256
257    /**
258     * {@inheritDoc}
259     * This tests for equal ID values
260     */
261    @Override
262    public boolean equals(Object obj) {
263        if (obj == null) {
264            return false;
265        }
266        if (getClass() != obj.getClass()) {
267            return false;
268        }
269        final Profile other = (Profile) obj;
270        return !((this.id == null) ? (other.id != null) : !this.id.equals(other.id));
271    }
272
273    /**
274     * Test if the profile is complete. A profile is considered complete if it
275     * can be instantiated using {@link #Profile(java.io.File)} and has a
276     * profile.properties file within its "profile" directory.
277     *
278     * @return true if profile.properties exists where expected.
279     */
280    public boolean isComplete() {
281        return (new File(this.getPath(), Profile.SHARED_PROPERTIES)).exists();
282    }
283
284    /**
285     * Return the uniqueness portion of the Profile Id.
286     * <p>
287     * This portion of the Id is automatically generated when the profile is
288     * created.
289     *
290     * @return An eight-character String of alphanumeric characters.
291     */
292    public String getUniqueId() {
293        return this.id.substring(this.id.lastIndexOf('.') + 1);
294    }
295
296    /**
297     * Test if the given path or subdirectories contains a Profile.
298     *
299     * @param path Path to test.
300     * @return true if path or subdirectories contains a Profile.
301     * @since 3.9.4
302     */
303    public static boolean containsProfile(File path) {
304        if (path.isDirectory()) {
305            if (Profile.isProfile(path)) {
306                return true;
307            } else {
308                File[] files = path.listFiles();
309                if (files != null) {
310                    for (File file : files) {
311                        if (Profile.containsProfile(file)) {
312                            return true;
313                        }
314                    }
315                }
316            }
317        }
318        return false;
319    }
320
321    /**
322     * Test if the given path is within a directory that is a Profile.
323     *
324     * @param path Path to test.
325     * @return true if path or parent directories is a Profile.
326     * @since 3.9.4
327     */
328    public static boolean inProfile(File path) {
329        if (path.getParentFile() != null) {
330            if (Profile.isProfile(path.getParentFile())) {
331                return true;
332            }
333            return Profile.inProfile(path.getParentFile());
334        }
335        return false;
336    }
337
338    /**
339     * Test if the given path is a Profile.
340     *
341     * @param path Path to test.
342     * @return true if path is a Profile.
343     * @since 3.9.4
344     */
345    public static boolean isProfile(File path) {
346        if (path.exists() && path.isDirectory()) {
347            // version 2
348            if ((new File(path, SHARED_PROPERTIES)).canRead()) {
349                return true;
350            }
351            // version 1
352            if ((new File(path, PROPERTIES)).canRead() && !path.getName().equals(PROFILE)) {
353                return true;
354            }
355        }
356        return false;
357    }
358
359    @Override
360    public int compareTo(Profile o) {
361        if (this.equals(o)) {
362            return 0;
363        }
364        String thisString = "" + this.getName() + this.getPath();
365        String thatString = "" + o.getName() + o.getPath();
366        return thisString.compareTo(thatString);
367    }
368
369    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(Profile.class);
370}