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.Nonnull;
012
013import jmri.JmriException;
014import jmri.configurexml.ConfigXmlManager;
015import jmri.configurexml.JmriConfigureXmlException;
016import jmri.configurexml.XmlAdapter;
017import jmri.profile.Profile;
018import jmri.profile.ProfileUtils;
019import jmri.spi.PreferencesManager;
020import jmri.util.jdom.JDOMUtil;
021import jmri.util.prefs.AbstractPreferencesManager;
022import jmri.util.prefs.InitializationException;
023
024import org.jdom2.Element;
025import org.jdom2.JDOMException;
026import org.openide.util.lookup.ServiceProvider;
027import org.slf4j.Logger;
028import org.slf4j.LoggerFactory;
029
030/**
031 * Manager for Startup Actions. Reads preferences at startup and triggers
032 * actions, and is responsible for saving the preferences later.
033 *
034 * @author Randall Wood (C) 2015, 2016, 2020
035 */
036@ServiceProvider(service = PreferencesManager.class)
037public class StartupActionsManager extends AbstractPreferencesManager {
038
039    private final List<StartupModel> actions = new ArrayList<>();
040    private final HashMap<Class<? extends StartupModel>, StartupModelFactory> factories = new HashMap<>();
041    private boolean isDirty = false;
042    private boolean restartRequired = false;
043    public final static String STARTUP = "startup"; // NOI18N
044    public final static String NAMESPACE = "http://jmri.org/xml/schema/auxiliary-configuration/startup-4-3-5.xsd"; // NOI18N
045    public final static String NAMESPACE_OLD = "http://jmri.org/xml/schema/auxiliary-configuration/startup-2-9-6.xsd"; // NOI18N
046    private final static Logger log = LoggerFactory.getLogger(StartupActionsManager.class);
047
048    public StartupActionsManager() {
049        super();
050        for (StartupModelFactory factory : ServiceLoader.load(StartupModelFactory.class)) {
051            factory.initialize();
052            this.factories.put(factory.getModelClass(), factory);
053        }
054    }
055
056    /**
057     * {@inheritDoc}
058     * <p>
059     * Loads the startup action preferences and, if all required managers have
060     * initialized without exceptions, performs those actions. Startup actions
061     * are only performed if {@link StartupModel#isValid()} is true
062     * for the action. It is assumed that the action has retained an Exception
063     * that can be used to explain why isValid() is false.
064     */
065    @Override
066    public void initialize(Profile profile) throws InitializationException {
067        if (!this.isInitialized(profile)) {
068            boolean perform = true;
069            try {
070                this.requiresNoInitializedWithExceptions(profile, Bundle.getMessage("StartupActionsManager.RefusalToInitialize"));
071            } catch (InitializationException ex) {
072                perform = false;
073            }
074            try {
075                Element startup;
076                try {
077                    startup = JDOMUtil.toJDOMElement(ProfileUtils.getAuxiliaryConfiguration(profile).getConfigurationFragment(STARTUP, NAMESPACE, true));
078                } catch (NullPointerException ex) {
079                    log.debug("Reading element from version 2.9.6 namespace...");
080                    startup = JDOMUtil.toJDOMElement(ProfileUtils.getAuxiliaryConfiguration(profile).getConfigurationFragment(STARTUP, NAMESPACE_OLD, true));
081                }
082                for (Element action : startup.getChildren()) {
083                    String adapter = action.getAttributeValue("class"); // NOI18N
084                    String name = action.getAttributeValue("name"); // NOI18N
085                    String override = StartupActionModelUtil.getDefault().getOverride(name);
086                    if (override != null) {
087                        action.setAttribute("name", override);
088                        log.info("Overriding startup action class {} with {}", name, override);
089                        this.addInitializationException(profile, new InitializationException(Bundle.getMessage(Locale.ENGLISH, "StartupActionsOverriddenClasses", name, override),
090                                Bundle.getMessage("StartupActionsOverriddenClasses", name, override)));
091                        name = override; // after logging difference and creating error message
092                    }
093                    String type = action.getAttributeValue("type"); // NOI18N
094                    log.debug("Read {} {} adapter {}", type, name, adapter);
095                    try {
096                        log.debug("Creating {} {} adapter {}...", type, name, adapter);
097                        ((XmlAdapter) Class.forName(adapter).getDeclaredConstructor().newInstance()).load(action, null); // no perNode preferences
098                    } catch (ClassNotFoundException | InstantiationException | IllegalAccessException ex) {
099                        log.error("Unable to create {} for {}", adapter, action, ex);
100                        this.addInitializationException(profile, new InitializationException(Bundle.getMessage(Locale.ENGLISH, "StartupActionsCreationError", adapter, name),
101                                Bundle.getMessage("StartupActionsCreationError", adapter, name))); // NOI18N
102                    } catch (IllegalArgumentException | NoSuchMethodException | SecurityException | InvocationTargetException | JmriConfigureXmlException ex) {
103                        log.error("Unable to load {} into {}", action, adapter, ex);
104                        this.addInitializationException(profile, new InitializationException(Bundle.getMessage(Locale.ENGLISH, "StartupActionsLoadError", adapter, name),
105                                Bundle.getMessage("StartupActionsLoadError", adapter, name))); // NOI18N
106                    }
107                }
108            } catch (NullPointerException ex) {
109                // ignore - this indicates migration has not occurred
110                log.debug("No element to read");
111            }
112            if (perform) {
113                this.actions.stream().filter(action -> action.isValid()).forEachOrdered(action -> {
114                    try {
115                        if (action.isEnabled()) {
116                            action.performAction();
117                        }
118                    } catch (JmriException ex) {
119                        this.addInitializationException(profile, ex);
120                    }
121                });
122                ServiceLoader.load(StartupRunnable.class).forEach(Runnable::run);
123            }
124            this.isDirty = false;
125            this.restartRequired = false;
126            this.setInitialized(profile, true);
127            List<Exception> exceptions = this.getInitializationExceptions(profile);
128            if (exceptions.size() == 1) {
129                throw new InitializationException(exceptions.get(0));
130            } else if (exceptions.size() > 1) {
131                throw new InitializationException(Bundle.getMessage(Locale.ENGLISH, "StartupActionsMultipleErrors"),
132                        Bundle.getMessage("StartupActionsMultipleErrors")); // NOI18N
133            }
134        }
135    }
136
137    @Override
138    @Nonnull
139    public Set<Class<? extends PreferencesManager>> getRequires() {
140        return requireAllOther();
141    }
142
143    @Override
144    public synchronized void savePreferences(Profile profile) {
145        Element element = new Element(STARTUP, NAMESPACE);
146        actions.stream().forEach((action) -> {
147            log.debug("model is {} ({})", action.getName(), action);
148            if (action.getName() != null) {
149                Element e = ConfigXmlManager.elementFromObject(action, true);
150                if (e != null) {
151                    element.addContent(e);
152                }
153            } else {
154                // get an error with a stack trace if this occurs
155                log.error("model \"{}\" does not have a name.", action, new Exception());
156            }
157        });
158        try {
159            ProfileUtils.getAuxiliaryConfiguration(profile).putConfigurationFragment(JDOMUtil.toW3CElement(element), true);
160            this.isDirty = false;
161        } catch (JDOMException ex) {
162            log.error("Unable to create create XML", ex);
163        }
164    }
165
166    public StartupModel[] getActions() {
167        return this.actions.toArray(StartupModel[]::new);
168    }
169
170    @SuppressWarnings("unchecked")
171    public <T extends StartupModel> List<T> getActions(Class<T> type) {
172        ArrayList<T> result = new ArrayList<>();
173        this.actions.stream().filter((action) -> (type.isInstance(action))).forEach((action) -> {
174            result.add((T) action);
175        });
176        return result;
177    }
178
179    public StartupModel getActions(int index) {
180        return this.actions.get(index);
181    }
182
183    /**
184     * Insert a {@link jmri.util.startup.StartupModel} at the given position.
185     * Triggers an {@link java.beans.IndexedPropertyChangeEvent} where the old
186     * value is null and the new value is the inserted model.
187     *
188     * @param index The position where the model will be inserted
189     * @param model The model to be inserted
190     */
191    public void setActions(int index, StartupModel model) {
192        this.setActions(index, model, true);
193    }
194
195    private void setActions(int index, StartupModel model, boolean fireChange) {
196        if (!this.actions.contains(model)) {
197            this.actions.add(index, model);
198            this.setRestartRequired();
199            if (fireChange) {
200                this.fireIndexedPropertyChange(STARTUP, index, null, model);
201            }
202        }
203    }
204
205    /**
206     * Move a {@link jmri.util.startup.StartupModel} from position start to position
207     * end. Triggers an {@link java.beans.IndexedPropertyChangeEvent} where the
208     * index is end, the old value is start and the new value is the moved
209     * model.
210     *
211     * @param start the original position
212     * @param end   the new position
213     */
214    public void moveAction(int start, int end) {
215        StartupModel model = this.getActions(start);
216        this.removeAction(model, false);
217        this.setActions(end, model, false);
218        this.fireIndexedPropertyChange(STARTUP, end, start, model);
219    }
220
221    public void addAction(StartupModel model) {
222        this.setActions(this.actions.size(), model);
223    }
224
225    /**
226     * Remove a {@link jmri.util.startup.StartupModel}. Triggers an
227     * {@link java.beans.IndexedPropertyChangeEvent} where the index is the
228     * position of the removed model, the old value is the model, and the new
229     * value is null.
230     *
231     * @param model The startup action to remove
232     */
233    public void removeAction(StartupModel model) {
234        this.removeAction(model, true);
235    }
236
237    private void removeAction(StartupModel model, boolean fireChange) {
238        int index = this.actions.indexOf(model);
239        this.actions.remove(model);
240        this.setRestartRequired();
241        if (fireChange) {
242            this.fireIndexedPropertyChange(STARTUP, index, model, null);
243        }
244    }
245
246    public HashMap<Class<? extends StartupModel>, StartupModelFactory> getFactories() {
247        return new HashMap<>(this.factories);
248    }
249
250    public StartupModelFactory getFactories(Class<? extends StartupModel> model) {
251        return this.factories.get(model);
252    }
253
254    public boolean isDirty() {
255        return this.isDirty;
256    }
257
258    /**
259     * Mark that a change requires a restart. As a side effect, marks this
260     * manager dirty.
261     */
262    public void setRestartRequired() {
263        this.restartRequired = true;
264        this.isDirty = true;
265    }
266
267    /**
268     * Indicate if a restart is required for preferences to be applied.
269     *
270     * @return true if a restart is required, false otherwise
271     */
272    public boolean isRestartRequired() {
273        return this.isDirty || this.restartRequired;
274    }
275}