001package jmri.implementation;
002
003import java.awt.GraphicsEnvironment;
004import java.awt.Toolkit;
005import java.awt.datatransfer.Clipboard;
006import java.awt.datatransfer.StringSelection;
007import java.awt.event.ActionEvent;
008import java.awt.event.ActionListener;
009import java.awt.event.KeyEvent;
010import java.io.File;
011import java.net.URISyntaxException;
012import java.net.URL;
013import java.util.*;
014import java.util.concurrent.atomic.AtomicBoolean;
015import java.util.concurrent.atomic.AtomicReference;
016
017import javax.swing.Action;
018import javax.swing.JFileChooser;
019import javax.swing.JList;
020import javax.swing.JMenuItem;
021import javax.swing.JPopupMenu;
022import javax.swing.KeyStroke;
023import javax.swing.TransferHandler;
024import javax.swing.event.ListSelectionEvent;
025
026import jmri.util.prefs.JmriPreferencesActionFactory;
027
028import jmri.Application;
029import jmri.ConfigureManager;
030import jmri.InstanceManager;
031import jmri.JmriException;
032import jmri.configurexml.ConfigXmlManager;
033import jmri.configurexml.swing.DialogErrorHandler;
034import jmri.jmrit.XmlFile;
035import jmri.jmrit.roster.RosterLocationUnavailableException;
036import jmri.profile.Profile;
037import jmri.profile.ProfileManager;
038import jmri.spi.PreferencesManager;
039import jmri.util.FileUtil;
040import jmri.util.SystemType;
041import jmri.util.com.sun.TransferActionListener;
042import jmri.util.prefs.HasConnectionButUnableToConnectException;
043import jmri.util.prefs.InitializationException;
044import jmri.util.swing.JmriJOptionPane;
045
046/**
047 *
048 * @author Randall Wood
049 */
050public class JmriConfigurationManager implements ConfigureManager {
051
052    private final ConfigXmlManager legacy = new ConfigXmlManager();
053    private final HashMap<PreferencesManager, InitializationException> initializationExceptions = new HashMap<>();
054    /*
055     * This list is in order of initialization and is used to display errors in
056     * the order they appear.
057     */
058    private final List<PreferencesManager> initialized = new ArrayList<>();
059    /*
060     * This set is used to prevent a stack overflow by preventing
061     * initializeProvider from recursively being called with the same provider.
062     */
063    private final Set<PreferencesManager> initializing = new HashSet<>();
064
065    public JmriConfigurationManager() {
066        ServiceLoader<PreferencesManager> sl = ServiceLoader.load(PreferencesManager.class);
067        for (PreferencesManager pp : sl) {
068            InstanceManager.store(pp, PreferencesManager.class);
069
070            for (Class<?> provided : pp.getProvides()) {
071                InstanceManager.storeUnchecked(pp, provided);
072            }
073
074        }
075        Profile profile = ProfileManager.getDefault().getActiveProfile();
076        if (profile != null) {
077            this.legacy.setPrefsLocation(new File(profile.getPath(), Profile.CONFIG_FILENAME));
078        }
079        if (!GraphicsEnvironment.isHeadless()) {
080            ConfigXmlManager.setErrorHandler(new DialogErrorHandler());
081        }
082    }
083
084    @Override
085    public void registerPref(Object o) {
086        if ((o instanceof PreferencesManager)) {
087            InstanceManager.store((PreferencesManager) o, PreferencesManager.class);
088        }
089        this.legacy.registerPref(o);
090    }
091
092    @Override
093    public void removePrefItems() {
094        this.legacy.removePrefItems();
095    }
096
097    @Override
098    public void registerConfig(Object o) {
099        this.legacy.registerConfig(o);
100    }
101
102    @Override
103    public void registerConfig(Object o, int x) {
104        this.legacy.registerConfig(o, x);
105    }
106
107    @Override
108    public void registerTool(Object o) {
109        this.legacy.registerTool(o);
110    }
111
112    @Override
113    public void registerUser(Object o) {
114        this.legacy.registerUser(o);
115    }
116
117    @Override
118    public void registerUserPrefs(Object o) {
119        this.legacy.registerUserPrefs(o);
120    }
121
122    @Override
123    public void deregister(Object o) {
124        this.legacy.deregister(o);
125    }
126
127    @Override
128    public Object findInstance(Class<?> c, int index) {
129        return this.legacy.findInstance(c, index);
130    }
131
132    @Override
133    public List<Object> getInstanceList(Class<?> c) {
134        return this.legacy.getInstanceList(c);
135    }
136
137    /**
138     * Save preferences. Preferences are saved using either the
139     * {@link jmri.util.prefs.JmriConfigurationProvider} or
140     * {@link jmri.util.prefs.JmriPreferencesProvider} as appropriate to the
141     * register preferences handler.
142     */
143    @Override
144    public void storePrefs() {
145        log.debug("Saving preferences...");
146        Profile profile = ProfileManager.getDefault().getActiveProfile();
147        InstanceManager.getList(PreferencesManager.class).stream().forEach((o) -> {
148            log.debug("Saving preferences for {}", o.getClass().getName());
149            o.savePreferences(profile);
150        });
151    }
152
153    /**
154     * Save preferences. This method calls {@link #storePrefs() }.
155     *
156     * @param file Ignored.
157     */
158    @Override
159    public void storePrefs(File file) {
160        this.storePrefs();
161    }
162
163    @Override
164    public void storeUserPrefs(File file) {
165        this.legacy.storeUserPrefs(file);
166    }
167
168    @Override
169    public boolean storeConfig(File file) {
170        return this.legacy.storeConfig(file);
171    }
172
173    @Override
174    public boolean storeUser(File file) {
175        return this.legacy.storeUser(file);
176    }
177
178    @Override
179    public boolean load(File file) throws JmriException {
180        return this.load(file, false);
181    }
182
183    @Override
184    public boolean load(URL url) throws JmriException {
185        return this.load(url, false);
186    }
187
188    @Override
189    public boolean load(File file, boolean registerDeferred) throws JmriException {
190        return this.load(FileUtil.fileToURL(file), registerDeferred);
191    }
192
193    @Override
194    public boolean load(URL url, boolean registerDeferred) throws JmriException {
195        log.debug("loading {} ...", url);
196        try {
197            if (url == null
198                    || (new File(url.toURI())).getName().equals(Profile.CONFIG_FILENAME)
199                    || (new File(url.toURI())).getName().equals(Profile.CONFIG)) {
200                Profile profile = ProfileManager.getDefault().getActiveProfile();
201                List<PreferencesManager> providers = new ArrayList<>(InstanceManager.getList(PreferencesManager.class));
202                providers.stream()
203                        // sorting is a best-effort attempt to ensure that the
204                        // more providers a provider relies on the later it will
205                        // be initialized; this should tend to cause providers
206                        // that list explicit requirements get run before providers
207                        // attempting to force themselves to run last by requiring
208                        // all providers
209                        .sorted(Comparator.comparingInt(p -> p.getRequires().size()))
210                        .forEachOrdered(provider -> initializeProvider(provider, profile));
211                if (!this.initializationExceptions.isEmpty()) {
212                    handleInitializationExceptions(profile);
213                }
214                if (url != null && (new File(url.toURI())).getName().equals(Profile.CONFIG_FILENAME)) {
215                    log.debug("Loading legacy configuration...");
216                    return this.legacy.load(url, registerDeferred);
217                }
218                return this.initializationExceptions.isEmpty();
219            }
220        } catch (URISyntaxException ex) {
221            log.error("Unable to get File for {}", url);
222            throw new JmriException(ex.getMessage(), ex);
223        }
224        // make this url the default "Store Panels..." file
225        try {
226            JFileChooser ufc = jmri.configurexml.StoreXmlUserAction.getUserFileChooser();
227            ufc.setSelectedFile(new File(FileUtil.urlToURI(url)));
228        } catch (Exception e) {
229            // A user was seeing an IndexOutOfBoundsException in the setSelectedFile above
230            // when loading a file at startup.  
231            // We don't know why, but see https://stackoverflow.com/questions/37322892/jfilechooser-java-lang-indexoutofboundsexception-invalid-index
232            // and https://web.archive.org/web/20170924021323/http://bugs.java.com/view_bug.do?bug_id=6684952
233            // This lets operation proceed past that exception.
234            log.error("Exception caught while setting default load file in file chooser: {}", e.toString());
235        }
236
237        return this.legacy.load(url, registerDeferred);
238        // return true; // always return true once legacy support is dropped
239    }
240
241    private void handleInitializationExceptions(Profile profile) {
242        if (!GraphicsEnvironment.isHeadless()) {
243
244            AtomicBoolean isUnableToConnect = new AtomicBoolean(false);
245            AtomicReference<RosterLocationUnavailableException> rosterUnavailable = new AtomicReference<>();
246
247            List<String> errors = new ArrayList<>();
248            this.initialized.forEach((provider) -> {
249                List<Exception> exceptions = provider.getInitializationExceptions(profile);
250                if (!exceptions.isEmpty()) {
251                    exceptions.forEach((exception) -> {
252                        if (exception instanceof HasConnectionButUnableToConnectException) {
253                            isUnableToConnect.set(true);
254                        }
255                        errors.add(exception.getLocalizedMessage());
256                    });
257                } else if (this.initializationExceptions.get(provider) != null) {
258                    InitializationException stored = this.initializationExceptions.get(provider);
259                    if (stored instanceof RosterLocationUnavailableException) {
260                        rosterUnavailable.compareAndSet(null, (RosterLocationUnavailableException) stored);
261                    }
262                    errors.add(stored.getLocalizedMessage());
263                }
264            });
265
266            RosterLocationUnavailableException rosterUnavailException = rosterUnavailable.get();
267            if (rosterUnavailException != null && handleRosterLocationUnavailable(rosterUnavailException)) {
268                return; // user chose Quit; ShutDownManager will end the JVM
269            }
270
271            Object list = getErrorListObject(errors);
272
273            if (isUnableToConnect.get()) {
274                handleConnectionError(errors, list);
275            } else {
276                displayErrorListDialog(list);
277            }
278        }
279    }
280
281    /**
282     * Show a Continue/Quit dialog when the configured roster location is
283     * unavailable at startup. Returns true if the user chose Quit (in which
284     * case {@link #handleQuit()} has been invoked); returns false only if the
285     * user explicitly chose Continue. Dismissing the dialog (close button) is
286     * treated as Quit, matching the profile chooser dialog's behavior.
287     *
288     * @param ex the exception describing the unavailable roster location
289     * @return true if the user chose to quit
290     */
291    private boolean handleRosterLocationUnavailable(RosterLocationUnavailableException ex) {
292        Object[] options = {
293            Bundle.getMessage("ErrorDialogButtonContinue"),
294            Bundle.getMessage("ErrorDialogButtonQuitProgram", Application.getApplicationName())
295        };
296        Object[] message = {
297            Bundle.getMessage("RosterLocationUnavailableText", ex.getUnavailablePath()),
298            Bundle.getMessage("RosterLocationUnavailablePrompt")
299        };
300        int choice = JmriJOptionPane.showOptionDialog(
301                null,
302                message,
303                Bundle.getMessage("RosterLocationUnavailableTitle"),
304                JmriJOptionPane.DEFAULT_OPTION,
305                JmriJOptionPane.WARNING_MESSAGE,
306                null,
307                options,
308                options[0]);
309        if (choice == 0) {
310            return false; // explicit Continue
311        }
312        handleQuit();
313        return true;
314    }
315
316    private Object getErrorListObject(List<String> errors) {
317        Object list;
318        if (errors.size() == 1) {
319            list = errors.get(0);
320        } else {
321            list = new JList<>(errors.toArray(new String[0]));
322        }
323        return list;
324    }
325
326    protected void displayErrorListDialog(Object list) {
327        JmriJOptionPane.showMessageDialog(null,
328                new Object[]{
329                    (list instanceof JList) ? Bundle.getMessage("InitExMessageListHeader") : null,
330                    list,
331                    "<html><br></html>", // Add a visual break between list of errors and notes // NOI18N
332                    Bundle.getMessage("InitExMessageLogs"), // NOI18N
333                    Bundle.getMessage("InitExMessagePrefs"), // NOI18N
334                },
335                Bundle.getMessage("InitExMessageTitle", Application.getApplicationName()), // NOI18N
336                JmriJOptionPane.ERROR_MESSAGE);
337            InstanceManager.getDefault(JmriPreferencesActionFactory.class)
338                    .getDefaultAction().actionPerformed(new ActionEvent(this,ActionEvent.ACTION_PERFORMED,""));
339    }
340
341    /**
342     * Show a dialog with options Quit, Restart, Change profile, Edit connections
343     * @param errors the list of error messages
344     * @param list A JList or a String with error message(s)
345     */
346    private void handleConnectionError(List<String> errors, Object list) {
347        List<String> errorList = errors;
348
349        errorList.add(" "); // blank line below errors
350        errorList.add(Bundle.getMessage("InitExMessageLogs"));
351
352        Object[] options = generateErrorDialogButtonOptions();
353
354        if (list instanceof JList) {
355            JPopupMenu popupMenu = new JPopupMenu();
356            JMenuItem copyMenuItem = buildCopyMenuItem((JList<?>) list);
357            popupMenu.add(copyMenuItem);
358
359            JMenuItem copyAllMenuItem = buildCopyAllMenuItem((JList<?>) list);
360            popupMenu.add(copyAllMenuItem);
361
362            ((JList<?>) list).setComponentPopupMenu(popupMenu);
363
364            ((JList<?>) list).addListSelectionListener((ListSelectionEvent e) -> copyMenuItem.setEnabled(((JList<?>)e.getSource()).getSelectedIndex() != -1));
365        }
366
367        handleRestartSelection(getjOptionPane(list, options));
368
369    }
370
371    // see order of generateErrorDialogButtonOptions()
372    // -1 - dialog closed, 0 - quit, 1 - continue, 2 - editconns
373    private void handleRestartSelection(int selectedValue) {
374        if (selectedValue == 0) {
375            // Exit program
376            handleQuit();
377
378        } else if (selectedValue == 1 || selectedValue == -1 ) {
379            // Do nothing. Let the program continue
380
381        } else if (selectedValue == 2) {
382           if (isEditDialogRestart()) {
383               handleRestart();
384           } else {
385                // Quit program
386                handleQuit();
387            }
388
389        } else {
390            // Exit program
391            handleQuit();
392        }
393    }
394
395    protected boolean isEditDialogRestart() {
396        return false;
397    }
398
399    protected void handleRestart() {
400        // Restart program
401        try {
402            InstanceManager.getDefault(jmri.ShutDownManager.class).restart();
403        } catch (Exception er) {
404            log.error("Continuing after error in handleRestart", er);
405        }
406    }
407
408
409    private int getjOptionPane(Object list, Object[] options) {
410        return JmriJOptionPane.showOptionDialog(
411            null, 
412            new Object[] {
413                (list instanceof JList) ? Bundle.getMessage("InitExMessageListHeader") : null,
414                list,
415                "<html><br></html>", // Add a visual break between list of errors and notes
416                Bundle.getMessage("InitExMessageLogs"),
417                Bundle.getMessage("ErrorDialogConnectLayout")}, 
418            Bundle.getMessage("InitExMessageTitle", Application.getApplicationName()), 
419            JmriJOptionPane.DEFAULT_OPTION, 
420            JmriJOptionPane.ERROR_MESSAGE, 
421            null, 
422            options, 
423            null);
424    }
425
426    private JMenuItem buildCopyAllMenuItem(JList<?> list) {
427        JMenuItem copyAllMenuItem = new JMenuItem(Bundle.getMessage("MenuItemCopyAll"));
428        ActionListener copyAllActionListener = (ActionEvent e) -> {
429            StringBuilder text = new StringBuilder();
430            for (int i = 0; i < list.getModel().getSize(); i++) {
431                text.append(list.getModel().getElementAt(i).toString());
432                text.append(System.getProperty("line.separator")); // NOI18N
433            }
434            Clipboard systemClipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
435            systemClipboard.setContents(new StringSelection(text.toString()), null);
436        };
437        copyAllMenuItem.setActionCommand("copyAll"); // NOI18N
438        copyAllMenuItem.addActionListener(copyAllActionListener);
439        return copyAllMenuItem;
440    }
441
442    private JMenuItem buildCopyMenuItem(JList<?> list) {
443        JMenuItem copyMenuItem = new JMenuItem(Bundle.getMessage("MenuItemCopy"));
444        TransferActionListener copyActionListener = new TransferActionListener();
445        copyMenuItem.setActionCommand((String) TransferHandler.getCopyAction().getValue(Action.NAME));
446        copyMenuItem.addActionListener(copyActionListener);
447        if (SystemType.isMacOSX()) {
448            copyMenuItem.setAccelerator(
449                    KeyStroke.getKeyStroke(KeyEvent.VK_C, ActionEvent.META_MASK));
450        } else {
451            copyMenuItem.setAccelerator(
452                    KeyStroke.getKeyStroke(KeyEvent.VK_C, ActionEvent.CTRL_MASK));
453        }
454        copyMenuItem.setMnemonic(KeyEvent.VK_C);
455        copyMenuItem.setEnabled(list.getSelectedIndex() != -1);
456        return copyMenuItem;
457    }
458
459    private Object[] generateErrorDialogButtonOptions() {
460        return new Object[] {
461                Bundle.getMessage("ErrorDialogButtonQuitProgram", Application.getApplicationName()),
462                Bundle.getMessage("ErrorDialogButtonContinue"),
463                Bundle.getMessage("ErrorDialogButtonEditConnections")
464            };
465    }
466
467    protected void handleQuit(){
468        try {
469            InstanceManager.getDefault(jmri.ShutDownManager.class).shutdown();
470        } catch (Exception e) {
471            log.error("Continuing after error in handleQuit", e);
472        }
473    }
474
475    @Override
476    public boolean loadDeferred(File file) {
477        return this.legacy.loadDeferred(file);
478    }
479
480    @Override
481    public boolean loadDeferred(URL file) {
482        return this.legacy.loadDeferred(file);
483    }
484
485    @Override
486    public URL find(String filename) {
487        return this.legacy.find(filename);
488    }
489
490    @Override
491    public boolean makeBackup(File file) {
492        return this.legacy.makeBackup(file);
493    }
494
495    private void initializeProvider(PreferencesManager provider, Profile profile) {
496        if (!initializing.contains(provider) && !provider.isInitialized(profile) && !provider.isInitializedWithExceptions(profile)) {
497            initializing.add(provider);
498            log.debug("Initializing provider {}", provider.getClass());
499            provider.getRequires()
500                    .forEach(c -> InstanceManager.getList(c)
501                            .forEach(p -> initializeProvider(p, profile)));
502            try {
503                provider.initialize(profile);
504            } catch (InitializationException ex) {
505                // log all initialization exceptions, but only retain for GUI display the
506                // first initialization exception for a provider
507                if (this.initializationExceptions.putIfAbsent(provider, ex) == null) {
508                    log.error("Exception initializing {}: {}", provider.getClass().getName(), ex.getMessage());
509                } else {
510                    log.error("Additional exception initializing {}: {}", provider.getClass().getName(), ex.getMessage());
511                }
512            }
513            this.initialized.add(provider);
514            log.debug("Initialized provider {}", provider.getClass());
515            initializing.remove(provider);
516        }
517    }
518
519    public HashMap<PreferencesManager, InitializationException> getInitializationExceptions() {
520        return new HashMap<>(initializationExceptions);
521    }
522
523    @Override
524    public void setValidate(XmlFile.Validate v) {
525        legacy.setValidate(v);
526    }
527
528    @Override
529    public XmlFile.Validate getValidate() {
530        return legacy.getValidate();
531    }
532
533    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(JmriConfigurationManager.class);
534
535}