001package jmri.util.startup; 002 003import java.lang.reflect.InvocationTargetException; 004import java.util.ArrayList; 005import java.util.HashMap; 006import java.util.List; 007import java.util.Locale; 008import java.util.ServiceLoader; 009import java.util.Set; 010 011import javax.annotation.CheckForNull; 012import javax.annotation.Nonnull; 013 014import jmri.InstanceManager; 015import jmri.JmriException; 016import jmri.SystemConnectionMemo; 017import jmri.configurexml.ConfigXmlManager; 018import jmri.configurexml.JmriConfigureXmlException; 019import jmri.configurexml.XmlAdapter; 020import jmri.profile.Profile; 021import jmri.profile.ProfileUtils; 022import jmri.spi.PreferencesManager; 023import jmri.util.jdom.JDOMUtil; 024import jmri.util.prefs.AbstractPreferencesManager; 025import jmri.util.prefs.InitializationException; 026 027import org.jdom2.Element; 028import org.jdom2.JDOMException; 029import org.openide.util.lookup.ServiceProvider; 030import org.slf4j.Logger; 031import org.slf4j.LoggerFactory; 032 033/** 034 * Manager for Startup Actions. Reads preferences at startup and triggers 035 * actions, and is responsible for saving the preferences later. 036 * 037 * @author Randall Wood (C) 2015, 2016, 2020 038 */ 039@ServiceProvider(service = PreferencesManager.class) 040public class StartupActionsManager extends AbstractPreferencesManager { 041 042 private final List<StartupModel> actions = new ArrayList<>(); 043 private final HashMap<Class<? extends StartupModel>, StartupModelFactory> factories = new HashMap<>(); 044 private boolean isDirty = false; 045 private boolean restartRequired = false; 046 public static final String STARTUP = "startup"; // NOI18N 047 public static final String NAMESPACE = "http://jmri.org/xml/schema/auxiliary-configuration/startup-4-3-5.xsd"; // NOI18N 048 public static final String NAMESPACE_OLD = "http://jmri.org/xml/schema/auxiliary-configuration/startup-2-9-6.xsd"; // NOI18N 049 private static final Logger log = LoggerFactory.getLogger(StartupActionsManager.class); 050 051 public StartupActionsManager() { 052 super(); 053 for (StartupModelFactory factory : ServiceLoader.load(StartupModelFactory.class)) { 054 factory.initialize(); 055 this.factories.put(factory.getModelClass(), factory); 056 } 057 } 058 059 /** 060 * {@inheritDoc} 061 * <p> 062 * Loads the startup action preferences and, if all required managers have 063 * initialized without exceptions, performs those actions. Startup actions 064 * are only performed if {@link StartupModel#isValid()} is true 065 * for the action. It is assumed that the action has retained an Exception 066 * that can be used to explain why isValid() is false. 067 */ 068 @Override 069 public void initialize(Profile profile) throws InitializationException { 070 if (!this.isInitialized(profile)) { 071 boolean perform = true; 072 try { 073 this.requiresNoInitializedWithExceptions(profile, Bundle.getMessage("StartupActionsManager.RefusalToInitialize")); 074 } catch (InitializationException ex) { 075 perform = false; 076 } 077 try { 078 Element startup; 079 try { 080 startup = JDOMUtil.toJDOMElement(ProfileUtils.getAuxiliaryConfiguration(profile).getConfigurationFragment(STARTUP, NAMESPACE, true)); 081 } catch (NullPointerException ex) { 082 log.debug("Reading element from version 2.9.6 namespace..."); 083 startup = JDOMUtil.toJDOMElement(ProfileUtils.getAuxiliaryConfiguration(profile).getConfigurationFragment(STARTUP, NAMESPACE_OLD, true)); 084 } 085 for (Element action : startup.getChildren()) { 086 String adapter = action.getAttributeValue("class"); // NOI18N 087 String name = action.getAttributeValue("name"); // NOI18N 088 String override = StartupActionModelUtil.getDefault().getOverride(name); 089 if (override != null) { 090 action.setAttribute("name", override); 091 log.info("Overriding startup action class {} with {}", name, override); 092 this.addInitializationException(profile, new InitializationException(Bundle.getMessage(Locale.ENGLISH, "StartupActionsOverriddenClasses", name, override), 093 Bundle.getMessage("StartupActionsOverriddenClasses", name, override))); 094 name = override; // after logging difference and creating error message 095 } 096 String type = action.getAttributeValue("type"); // NOI18N 097 log.debug("Read {} {} adapter {}", type, name, adapter); 098 try { 099 log.debug("Creating {} {} adapter {}...", type, name, adapter); 100 ((XmlAdapter) Class.forName(adapter).getDeclaredConstructor().newInstance()).load(action, null); // no perNode preferences 101 } catch (ClassNotFoundException | InstantiationException | IllegalAccessException ex) { 102 log.error("Unable to create {} for {}", adapter, action, ex); 103 this.addInitializationException(profile, new InitializationException(Bundle.getMessage(Locale.ENGLISH, "StartupActionsCreationError", adapter, name), 104 Bundle.getMessage("StartupActionsCreationError", adapter, name))); // NOI18N 105 } catch (IllegalArgumentException | NoSuchMethodException | SecurityException | InvocationTargetException | JmriConfigureXmlException ex) { 106 log.error("Unable to load {} into {}", action, adapter, ex); 107 this.addInitializationException(profile, new InitializationException(Bundle.getMessage(Locale.ENGLISH, "StartupActionsLoadError", adapter, name), 108 Bundle.getMessage("StartupActionsLoadError", adapter, name))); // NOI18N 109 } 110 } 111 } catch (NullPointerException ex) { 112 // ignore - this indicates migration has not occurred 113 log.debug("No element to read"); 114 } 115 if (perform) { 116 this.actions.stream().filter(action -> action.isValid()).forEachOrdered(action -> { 117 try { 118 if (action.isEnabled()) { 119 action.performAction(); 120 } 121 } catch (JmriException ex) { 122 this.addInitializationException(profile, ex); 123 } 124 }); 125 ServiceLoader.load(StartupRunnable.class).forEach(Runnable::run); 126 } 127 this.isDirty = false; 128 this.restartRequired = false; 129 this.setInitialized(profile, true); 130 List<Exception> exceptions = this.getInitializationExceptions(profile); 131 if (exceptions.size() == 1) { 132 throw new InitializationException(exceptions.get(0)); 133 } else if (exceptions.size() > 1) { 134 throw new InitializationException(Bundle.getMessage(Locale.ENGLISH, "StartupActionsMultipleErrors"), 135 Bundle.getMessage("StartupActionsMultipleErrors")); // NOI18N 136 } 137 } 138 } 139 140 @Override 141 @Nonnull 142 public Set<Class<? extends PreferencesManager>> getRequires() { 143 return requireAllOther(); 144 } 145 146 @Override 147 public synchronized void savePreferences(Profile profile) { 148 Element element = new Element(STARTUP, NAMESPACE); 149 actions.stream().forEach((action) -> { 150 log.debug("model is {} ({})", action.getName(), action); 151 if (action.getName() != null || isKeptAction(action)) { 152 Element e = ConfigXmlManager.elementFromObject(action, true); 153 if (e != null) { 154 element.addContent(e); 155 } 156 } else { 157 // get an error with a stack trace if this occurs 158 log.error("model \"{}\" does not have a name.", action, new Exception()); 159 } 160 }); 161 try { 162 ProfileUtils.getAuxiliaryConfiguration(profile).putConfigurationFragment(JDOMUtil.toW3CElement(element), true); 163 this.isDirty = false; 164 } catch (JDOMException ex) { 165 log.error("Unable to create create XML", ex); 166 } 167 } 168 169 /** 170 * Check if an action without a display name should still be saved. 171 * <p> 172 * An action's display name is only known while a connection or factory 173 * offering it is running. An action for a connection that is disabled, 174 * failed to start, or was removed in this session has no name, but it 175 * still has its class name and connection prefix. It is kept so that it 176 * works again when the connection comes back, rather than being silently 177 * deleted from the user's preferences. 178 * 179 * @param action the action to check 180 * @return true if the action is identified by a class name 181 */ 182 private static boolean isKeptAction(StartupModel action) { 183 if (action instanceof AbstractActionModel) { 184 String className = ((AbstractActionModel) action).getClassName(); 185 if (className != null && !className.isEmpty()) { 186 log.debug("keeping action {} for connection \"{}\" not currently available", 187 className, ((AbstractActionModel) action).getSystemPrefix()); 188 return true; 189 } 190 } 191 return false; 192 } 193 194 public StartupModel[] getActions() { 195 return this.actions.toArray(StartupModel[]::new); 196 } 197 198 @SuppressWarnings("unchecked") 199 public <T extends StartupModel> List<T> getActions(Class<T> type) { 200 ArrayList<T> result = new ArrayList<>(); 201 this.actions.stream().filter((action) -> (type.isInstance(action))).forEach((action) -> { 202 result.add((T) action); 203 }); 204 return result; 205 } 206 207 public StartupModel getActions(int index) { 208 return this.actions.get(index); 209 } 210 211 /** 212 * Insert a {@link jmri.util.startup.StartupModel} at the given position. 213 * Triggers an {@link java.beans.IndexedPropertyChangeEvent} where the old 214 * value is null and the new value is the inserted model. 215 * 216 * @param index The position where the model will be inserted 217 * @param model The model to be inserted 218 */ 219 public void setActions(int index, StartupModel model) { 220 this.setActions(index, model, true); 221 } 222 223 private void setActions(int index, StartupModel model, boolean fireChange) { 224 if (!this.actions.contains(model)) { 225 this.actions.add(index, model); 226 this.setRestartRequired(); 227 if (fireChange) { 228 this.fireIndexedPropertyChange(STARTUP, index, null, model); 229 } 230 } 231 } 232 233 /** 234 * Move a {@link jmri.util.startup.StartupModel} from position start to position 235 * end. Triggers an {@link java.beans.IndexedPropertyChangeEvent} where the 236 * index is end, the old value is start and the new value is the moved 237 * model. 238 * 239 * @param start the original position 240 * @param end the new position 241 */ 242 public void moveAction(int start, int end) { 243 StartupModel model = this.getActions(start); 244 this.removeAction(model, false); 245 this.setActions(end, model, false); 246 this.fireIndexedPropertyChange(STARTUP, end, start, model); 247 } 248 249 public void addAction(StartupModel model) { 250 this.setActions(this.actions.size(), model); 251 } 252 253 /** 254 * Remove a {@link jmri.util.startup.StartupModel}. Triggers an 255 * {@link java.beans.IndexedPropertyChangeEvent} where the index is the 256 * position of the removed model, the old value is the model, and the new 257 * value is null. 258 * 259 * @param model The startup action to remove 260 */ 261 public void removeAction(StartupModel model) { 262 this.removeAction(model, true); 263 } 264 265 private void removeAction(StartupModel model, boolean fireChange) { 266 int index = this.actions.indexOf(model); 267 this.actions.remove(model); 268 this.setRestartRequired(); 269 if (fireChange) { 270 this.fireIndexedPropertyChange(STARTUP, index, model, null); 271 } 272 } 273 274 /** 275 * Get the startup actions that belong to a system connection. 276 * 277 * @param systemPrefix the system prefix of the connection 278 * @return the actions for that connection, in Start Up order; empty if none 279 */ 280 @Nonnull 281 public List<AbstractActionModel> getActionsForConnection(@Nonnull String systemPrefix) { 282 ArrayList<AbstractActionModel> result = new ArrayList<>(); 283 for (AbstractActionModel model : this.getActions(AbstractActionModel.class)) { 284 if (model.isSystemConnectionAction() && systemPrefix.equals(model.getSystemPrefix())) { 285 result.add(model); 286 } 287 } 288 return result; 289 } 290 291 /** 292 * Get the running connections that offer the action of a startup action, 293 * so the action could be moved to one of them. 294 * 295 * @param model the startup action 296 * @param excludedPrefix the system prefix of a connection to leave out, 297 * usually the one being deleted; may be null 298 * @return the connections offering the action; empty if none 299 */ 300 @Nonnull 301 public static List<SystemConnectionMemo> getConnectionsOffering(@Nonnull AbstractActionModel model, 302 @CheckForNull String excludedPrefix) { 303 ArrayList<SystemConnectionMemo> result = new ArrayList<>(); 304 String className = model.getClassName(); 305 for (SystemConnectionMemo memo : InstanceManager.getList(SystemConnectionMemo.class)) { 306 if (memo.getSystemPrefix().equals(excludedPrefix)) { 307 continue; 308 } 309 StartupActionFactory factory = memo.getActionFactory(); 310 if (factory == null) { 311 continue; 312 } 313 for (Class<?> clazz : factory.getActionClasses()) { 314 if (clazz.getName().equals(className)) { 315 result.add(memo); 316 break; 317 } 318 } 319 } 320 return result; 321 } 322 323 /** 324 * Move a startup action to a different system connection. Triggers an 325 * {@link java.beans.IndexedPropertyChangeEvent} where the index, and the 326 * old value, are the position of the model, and the new value is the 327 * model. 328 * 329 * @param model the startup action to move 330 * @param systemPrefix the system prefix of the connection to move it to 331 */ 332 public void reassignAction(@Nonnull AbstractActionModel model, @Nonnull String systemPrefix) { 333 int index = this.actions.indexOf(model); 334 if (index < 0) { 335 return; 336 } 337 model.setSystemPrefix(systemPrefix); 338 this.setRestartRequired(); 339 this.fireIndexedPropertyChange(STARTUP, index, index, model); 340 } 341 342 public HashMap<Class<? extends StartupModel>, StartupModelFactory> getFactories() { 343 return new HashMap<>(this.factories); 344 } 345 346 public StartupModelFactory getFactories(Class<? extends StartupModel> model) { 347 return this.factories.get(model); 348 } 349 350 public boolean isDirty() { 351 return this.isDirty; 352 } 353 354 /** 355 * Mark that a change requires a restart. As a side effect, marks this 356 * manager dirty. 357 */ 358 public void setRestartRequired() { 359 this.restartRequired = true; 360 this.isDirty = true; 361 } 362 363 /** 364 * Indicate if a restart is required for preferences to be applied. 365 * 366 * @return true if a restart is required, false otherwise 367 */ 368 public boolean isRestartRequired() { 369 return this.isDirty || this.restartRequired; 370 } 371}