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