001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.io;
003
004import java.io.File;
005import java.io.IOException;
006import java.nio.file.FileSystems;
007import java.nio.file.Path;
008import java.nio.file.StandardWatchEventKinds;
009import java.nio.file.WatchEvent;
010import java.nio.file.WatchEvent.Kind;
011import java.nio.file.WatchKey;
012import java.nio.file.WatchService;
013import java.util.Collections;
014import java.util.HashMap;
015import java.util.Map;
016
017import org.openstreetmap.josm.Main;
018import org.openstreetmap.josm.data.validation.OsmValidator;
019import org.openstreetmap.josm.data.validation.tests.MapCSSTagChecker;
020import org.openstreetmap.josm.gui.mappaint.MapPaintStyles.MapPaintStyleLoader;
021import org.openstreetmap.josm.gui.mappaint.StyleSource;
022import org.openstreetmap.josm.gui.mappaint.mapcss.parsergen.ParseException;
023import org.openstreetmap.josm.gui.preferences.SourceEntry;
024import org.openstreetmap.josm.tools.CheckParameterUtil;
025
026/**
027 * Background thread that monitors certain files and perform relevant actions when they change.
028 * @since 7185
029 */
030public class FileWatcher {
031
032    private WatchService watcher;
033    private Thread thread;
034
035    private final Map<Path, StyleSource> styleMap = new HashMap<>();
036    private final Map<Path, SourceEntry> ruleMap = new HashMap<>();
037
038    /**
039     * Constructs a new {@code FileWatcher}.
040     */
041    public FileWatcher() {
042        try {
043            watcher = FileSystems.getDefault().newWatchService();
044            thread = new Thread((Runnable) this::processEvents, "File Watcher");
045        } catch (IOException e) {
046            Main.error(e);
047        }
048    }
049
050    /**
051     * Starts the File Watcher thread.
052     */
053    public final void start() {
054        if (!thread.isAlive()) {
055            thread.start();
056        }
057    }
058
059    /**
060     * Registers a map paint style for local file changes, allowing dynamic reloading.
061     * @param style The style to watch
062     * @throws IllegalArgumentException if {@code style} is null or if it does not provide a local file
063     * @throws IllegalStateException if the watcher service failed to start
064     * @throws IOException if an I/O error occurs
065     */
066    public void registerStyleSource(StyleSource style) throws IOException {
067        register(style, styleMap);
068    }
069
070    /**
071     * Registers a validator rule for local file changes, allowing dynamic reloading.
072     * @param rule The rule to watch
073     * @throws IllegalArgumentException if {@code rule} is null or if it does not provide a local file
074     * @throws IllegalStateException if the watcher service failed to start
075     * @throws IOException if an I/O error occurs
076     * @since 7276
077     */
078    public void registerValidatorRule(SourceEntry rule) throws IOException {
079        register(rule, ruleMap);
080    }
081
082    private <T extends SourceEntry> void register(T obj, Map<Path, T> map) throws IOException {
083        CheckParameterUtil.ensureParameterNotNull(obj, "obj");
084        if (watcher == null) {
085            throw new IllegalStateException("File watcher is not available");
086        }
087        // Get local file, as this method is only called for local style sources
088        File file = new File(obj.url);
089        // Get parent directory as WatchService allows only to monitor directories, not single files
090        File dir = file.getParentFile();
091        if (dir == null) {
092            throw new IllegalArgumentException("Resource "+obj+" does not have a parent directory");
093        }
094        synchronized (this) {
095            // Register directory. Can be called several times for a same directory without problem
096            // (it returns the same key so it should not send events several times)
097            dir.toPath().register(watcher, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_CREATE);
098            map.put(file.toPath(), obj);
099        }
100    }
101
102    /**
103     * Process all events for the key queued to the watcher.
104     */
105    private void processEvents() {
106        if (Main.isDebugEnabled()) {
107            Main.debug("File watcher thread started");
108        }
109        while (true) {
110
111            // wait for key to be signaled
112            WatchKey key;
113            try {
114                key = watcher.take();
115            } catch (InterruptedException x) {
116                return;
117            }
118
119            for (WatchEvent<?> event: key.pollEvents()) {
120                Kind<?> kind = event.kind();
121
122                if (StandardWatchEventKinds.OVERFLOW.equals(kind)) {
123                    continue;
124                }
125
126                // The filename is the context of the event.
127                @SuppressWarnings("unchecked")
128                WatchEvent<Path> ev = (WatchEvent<Path>) event;
129                Path filename = ev.context();
130                if (filename == null) {
131                    continue;
132                }
133
134                // Only way to get full path (http://stackoverflow.com/a/7802029/2257172)
135                Path fullPath = ((Path) key.watchable()).resolve(filename);
136
137                synchronized (this) {
138                    StyleSource style = styleMap.get(fullPath);
139                    SourceEntry rule = ruleMap.get(fullPath);
140                    if (style != null) {
141                        Main.info("Map style "+style.getDisplayString()+" has been modified. Reloading style...");
142                        Main.worker.submit(new MapPaintStyleLoader(Collections.singleton(style)));
143                    } else if (rule != null) {
144                        Main.info("Validator rule "+rule.getDisplayString()+" has been modified. Reloading rule...");
145                        MapCSSTagChecker tagChecker = OsmValidator.getTest(MapCSSTagChecker.class);
146                        if (tagChecker != null) {
147                            try {
148                                tagChecker.addMapCSS(rule.url);
149                            } catch (IOException | ParseException e) {
150                                Main.warn(e);
151                            }
152                        }
153                    } else if (Main.isDebugEnabled()) {
154                        Main.debug("Received "+kind.name()+" event for unregistered file: "+fullPath);
155                    }
156                }
157            }
158
159            // Reset the key -- this step is critical to receive
160            // further watch events. If the key is no longer valid, the directory
161            // is inaccessible so exit the loop.
162            if (!key.reset()) {
163                break;
164            }
165        }
166    }
167}