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.Files;
008import java.nio.file.Path;
009import java.nio.file.StandardWatchEventKinds;
010import java.nio.file.WatchEvent;
011import java.nio.file.WatchEvent.Kind;
012import java.nio.file.WatchKey;
013import java.nio.file.WatchService;
014import java.util.EnumMap;
015import java.util.HashMap;
016import java.util.Map;
017import java.util.Objects;
018import java.util.function.Consumer;
019
020import org.openstreetmap.josm.data.preferences.sources.SourceEntry;
021import org.openstreetmap.josm.data.preferences.sources.SourceType;
022import org.openstreetmap.josm.tools.CheckParameterUtil;
023import org.openstreetmap.josm.tools.Logging;
024
025/**
026 * Background thread that monitors certain files and perform relevant actions when they change.
027 * @since 7185
028 */
029public class FileWatcher {
030
031    private WatchService watcher;
032    private Thread thread;
033
034    private static final Map<SourceType, Consumer<SourceEntry>> loaderMap = new EnumMap<>(SourceType.class);
035    private final Map<Path, SourceEntry> sourceMap = new HashMap<>();
036
037    private static class InstanceHolder {
038        static final FileWatcher INSTANCE = new FileWatcher();
039    }
040
041    /**
042     * Returns the default instance.
043     * @return the default instance
044     * @since 14128
045     */
046    public static FileWatcher getDefaultInstance() {
047        return InstanceHolder.INSTANCE;
048    }
049
050    /**
051     * Constructs a new {@code FileWatcher}.
052     */
053    public FileWatcher() {
054        try {
055            watcher = FileSystems.getDefault().newWatchService();
056            thread = new Thread((Runnable) this::processEvents, "File Watcher");
057        } catch (IOException | UnsupportedOperationException | UnsatisfiedLinkError e) {
058            Logging.error(e);
059        }
060    }
061
062    /**
063     * Starts the File Watcher thread.
064     */
065    public final void start() {
066        if (thread != null && !thread.isAlive()) {
067            thread.start();
068        }
069    }
070
071    /**
072     * Registers a source for local file changes, allowing dynamic reloading.
073     * @param src The source to watch
074     * @throws IllegalArgumentException if {@code rule} is null or if it does not provide a local file
075     * @throws IllegalStateException if the watcher service failed to start
076     * @throws IOException if an I/O error occurs
077     * @since 12825
078     */
079    public void registerSource(SourceEntry src) throws IOException {
080        CheckParameterUtil.ensureParameterNotNull(src, "src");
081        if (watcher == null) {
082            throw new IllegalStateException("File watcher is not available");
083        }
084        // Get local file, as this method is only called for local style sources
085        File file = new File(src.url);
086        // Get parent directory as WatchService allows only to monitor directories, not single files
087        File dir = file.getParentFile();
088        if (dir == null) {
089            throw new IllegalArgumentException("Resource "+src+" does not have a parent directory");
090        }
091        synchronized (this) {
092            // Register directory. Can be called several times for a same directory without problem
093            // (it returns the same key so it should not send events several times)
094            dir.toPath().register(watcher, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_CREATE);
095            sourceMap.put(file.toPath(), src);
096        }
097    }
098
099    /**
100     * Registers a source loader, allowing dynamic reloading when an entry changes.
101     * @param type the source type for which the loader operates
102     * @param loader the loader in charge of reloading any source of given type when it changes
103     * @return the previous loader registered for this source type, if any
104     * @since 12825
105     */
106    public static Consumer<SourceEntry> registerLoader(SourceType type, Consumer<SourceEntry> loader) {
107        return loaderMap.put(Objects.requireNonNull(type, "type"), Objects.requireNonNull(loader, "loader"));
108    }
109
110    /**
111     * Process all events for the key queued to the watcher.
112     */
113    private void processEvents() {
114        Logging.debug("File watcher thread started");
115        while (true) {
116
117            // wait for key to be signaled
118            WatchKey key;
119            try {
120                key = watcher.take();
121            } catch (InterruptedException ex) {
122                Thread.currentThread().interrupt();
123                return;
124            }
125
126            for (WatchEvent<?> event: key.pollEvents()) {
127                Kind<?> kind = event.kind();
128
129                if (StandardWatchEventKinds.OVERFLOW.equals(kind)) {
130                    continue;
131                }
132
133                // The filename is the context of the event.
134                @SuppressWarnings("unchecked")
135                WatchEvent<Path> ev = (WatchEvent<Path>) event;
136                Path filename = ev.context();
137                if (filename == null) {
138                    continue;
139                }
140
141                // Only way to get full path (http://stackoverflow.com/a/7802029/2257172)
142                Path fullPath = ((Path) key.watchable()).resolve(filename);
143
144                try {
145                    // Some filesystems fire two events when a file is modified. Skip first event (file is empty)
146                    if (Files.size(fullPath) == 0) {
147                        continue;
148                    }
149                } catch (IOException ex) {
150                    Logging.trace(ex);
151                    continue;
152                }
153
154                synchronized (this) {
155                    SourceEntry source = sourceMap.get(fullPath);
156                    if (source != null) {
157                        Consumer<SourceEntry> loader = loaderMap.get(source.type);
158                        if (loader != null) {
159                            Logging.info("Source "+source.getDisplayString()+" has been modified. Reloading it...");
160                            loader.accept(source);
161                        } else {
162                            Logging.warn("Received {0} event for unregistered source type: {1}", kind.name(), source.type);
163                        }
164                    } else if (Logging.isDebugEnabled()) {
165                        Logging.debug("Received {0} event for unregistered file: {1}", kind.name(), fullPath);
166                    }
167                }
168            }
169
170            // Reset the key -- this step is critical to receive
171            // further watch events. If the key is no longer valid, the directory
172            // is inaccessible so exit the loop.
173            if (!key.reset()) {
174                break;
175            }
176        }
177    }
178}