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