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}