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}