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(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}