package org.geotoolkit.nio;
import org.apache.sis.util.ArgumentChecks;
import org.apache.sis.util.logging.Logging;
import javax.swing.event.EventListenerList;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.HashSet;
import java.util.logging.Level;
import java.util.logging.Logger;
import static java.nio.file.StandardWatchEventKinds.*;
/**
* Watch for file-system modification for a given folder. There's a recursive mode to allow watch of all sub-directories
* as well. If the recursive mode is activated, when a new directory is created in the watched one, it will be registered
* for watching, as all its sub-directories.
* <p/>
* Watched folders are registered for the standard event types defined by {@link java.nio.file.StandardWatchEventKinds}.
* <p/>
*
* Use of the class :
*
* - Build a new watcher, with a boolean argument to specify if we want recursive survey (true) or not (false) :
* final DirectoryWatcher watcher = new DirectoryWatcher(true) {..your implementation...};
* <p/>
* - Give it the separate folders to work on :
* final Path toWatch1, toWatch2;
* watcher.register(toWatch1, toWatch2);
* <p/>
* - Your watcher is ready to go, call the {@linkplain #start()} method to launch the survey.
* /!\ It launches an asynchronous survey in a separate thread.
* <p/>
* - To stop|pause the watching, you just have to call {@linkplain #stop()} method.
* <p/>
* - Finally, when you're done with survey, please close your watcher using {@linkplain #close()}.
* <p/>
*
* N.B : Thread-safe implementation.
*
* @author Alexis Manin (Geomatys)
*/
public class DirectoryWatcher implements Closeable {
private static final Logger LOGGER = Logging.getLogger("org.geotoolkit.io");
private final WatchService service;
private final Thread watchThread;
/**
* True if we listen changes of all the file tree, or only for the direct children of the registered folders.
*/
public final boolean recursive;
protected final HashSet<Path> roots = new HashSet<>();
protected final HashSet<Path> unregistered = new HashSet<>();
/**
* Filter used to determine which files must be ignored and which must be used.
*/
protected DirectoryStream.Filter<Path> fileFilter = null;
private final Object fileFilterLock = new Object();
protected final EventListenerList listeners = new EventListenerList();
/**
* Prepare watcher for NON-recursive survey.
*
* @throws IOException
*/
public DirectoryWatcher() throws IOException {
this(false);
}
/**
* Initialize watching service.
*
* @param recursive True if we want to watch over the entire file tree, false if we want to be notified only for
* direct childrens of root folder(s).
* @throws IOException If an error occurred while building underlying {@link java.nio.file.WatchService}.
*/
public DirectoryWatcher(final boolean recursive) throws IOException {
service = FileSystems.getDefault().newWatchService();
this.recursive = recursive;
watchThread = new Thread(new Runnable() {
@Override
public void run() {
watch();
}
});
watchThread.setDaemon(true);
}
/**
* Provide a filter to specify if a file must processed or ignored at change.
*
* @param filter A PathMatcher whose {@link java.nio.file.PathMatcher#matches(java.nio.file.Path)} method return
* true if we must process the path in parameter. If null, all changed files are processed.
*/
public void setFileFilter(final DirectoryStream.Filter<Path> filter) {
synchronized (fileFilterLock) {
fileFilter = filter;
}
}
/**
* @return the filter used to know if we must process or not a file. Null if no filtering applied.
*/
public DirectoryStream.Filter<Path> getFileFilter() {
synchronized (fileFilterLock) {
return fileFilter;
}
}
/**
* Register a directory to the WatchService. If it does not exists, we'll try to create it.
*
* @param paths A list of folders to watch. If one does not exists, we will try to create it.
* @throws java.io.IOException If an input folder does not exists and cannot be created, or if an error occurred
* trying to register it or one of its children.
* @throws java.nio.file.FileAlreadyExistsException If an input path denotes a file and not a folder.
* @throws SecurityException If an input directory cannot be watched due to a lack of permission.
*/
public synchronized void register(Path... paths) throws IOException, SecurityException {
for (Path toWatch : paths) {
if (!roots.contains(toWatch)) {
if (!Files.isDirectory(toWatch)) {
Files.createDirectories(toWatch);
}
registerDir(toWatch);
roots.add(toWatch);
}
// In case someone asked for its removal before.
unregistered.remove(toWatch);
}
}
public synchronized void unregister(Path... paths) {
for (Path dir : paths) {
unregistered.add(dir);
// in case it's a previously registered folder which is queried for removal, and not one of its children.
roots.remove(dir);
}
}
/**
* Register a directory along with all its sub-directories to the WatchService.
*/
private final void registerDir(final Path dir) throws IOException, SecurityException {
dir.register(service, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY);
if (recursive) {
Files.walkFileTree(dir, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
throws IOException {
dir.register(service, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY);
return FileVisitResult.CONTINUE;
}
});
}
}
/**
* Start to survey the registered folders (see {@linkplain #register(java.nio.file.Path...)}
*/
private void watch() {
final Thread currentThread = Thread.currentThread();
while (!currentThread.isInterrupted()) {
final WatchKey key;
try {
key = service.take();
} catch (ClosedWatchServiceException e) {
LOGGER.log(Level.INFO, "Folder watcher has been closed !");
break;
} catch (InterruptedException e) {
LOGGER.log(Level.INFO, "Folder watcher has been interrupted !");
currentThread.interrupt();
break;
}
// Do the check here, because we don't know how many time we have waited for an event, nor what happened.
if (currentThread.isInterrupted()) {
break;
}
// If could happen if we've cancelled if and it was already waiting for new events. We just have to ignore it.
if (!key.isValid()) {
continue;
}
// TODO : Change if an implementation of watchable other than path appears in the jvm.
final Path watchedPath = (Path) key.watchable();
try {
// Check if we must stop following the current folder.
final Path dereferenced = getUnregisteredParent(watchedPath);
if (dereferenced != null) {
key.cancel();
// If current event path represents the exact path to remove, not just a children, we can forget about it.
if (dereferenced.equals(watchedPath)) {
synchronized (unregistered) {
unregistered.remove(dereferenced);
}
}
continue;
}
for (WatchEvent event : key.pollEvents()) {
WatchEvent.Kind kind = event.kind();
// Watch service received too many events from the system and cannot handle them.
if (kind == OVERFLOW) {
LOGGER.log(Level.INFO, "Too many changes happened to the watched directory. Unable to catch them all.");
continue;
}
try {
Path target;
final Object context = event.context();
if (context instanceof Path) {
target = (Path) context;
} else if (context instanceof File) {
target = ((File) context).toPath();
} else {
// Create an exception to be able to retrieve code fragment from message, which will allow to quickly add new cases if needed.
final IllegalArgumentException e = new IllegalArgumentException("Watch event is of unknown type (need Path or File).");
LOGGER.log(Level.INFO, "Watch event skipped.", e);
continue;
}
target = watchedPath.resolve(target);
final boolean isDirectory = Files.isDirectory(target);
// Add the new directory to the watched ones if we're on recursive mode.
if (isDirectory && recursive && kind == ENTRY_CREATE) {
try {
registerDir(target);
} catch (IOException e) {
LOGGER.log(Level.WARNING, "Newly created folder cannot be watched : " + target, e);
}
}
final boolean matchFileFilter;
synchronized (fileFilterLock) {
matchFileFilter = (fileFilter == null || fileFilter.accept(target));
}
if (matchFileFilter) {
firePathChanged(target, kind, isDirectory, event.count());
}
} catch (Exception e) {
// We don't want the entire mechanism to be destroyed for a simple error on a file.
LOGGER.log(Level.WARNING, "An error occurred while processing " + event.context() + " for the event " + kind, e);
}
}
} finally {
/* Finished work with key, reset it. If we cannot, it means the bound directory is not watched anymore, we can
remove it from our list of surveyed folders.
*/
if (!key.reset()) {
synchronized (roots) {
roots.remove(watchedPath);
}
}
}
}
}
protected Path getUnregisteredParent(final Path target) {
synchronized (unregistered) {
for (Path path : unregistered) {
if (target.startsWith(path) || path.equals(target)) {
return path;
}
}
}
return null;
}
public void start() {
watchThread.start();
}
public void stop() {
watchThread.interrupt();
}
@Override
public synchronized void close() throws IOException {
stop();
synchronized (roots) {
roots.clear();
}
service.close();
}
public void addPathChangeListener(final PathChangeListener listener) {
ArgumentChecks.ensureNonNull("Event listener", listener);
listeners.add(PathChangeListener.class, listener);
}
public void removePathChangeListener(final PathChangeListener toForget) {
ArgumentChecks.ensureNonNull("Event listener", toForget);
listeners.remove(PathChangeListener.class, toForget);
}
/**
* Each time the watch service will catch an event on a file/folder, it will use this method. It allows user defining
* its own processes over file changes.
*
* N.B : If an exception is thrown by a listener, it is stored and thrown back after all listeners have been executed.
* If another listener throw an exception, it is stored via {@link java.lang.Exception#addSuppressed(Throwable)}.
*
* @param target The file which have been triggered for changes.
* @param kind The kind of change which applied on the file. Most likely one of the {@link java.nio.file.StandardWatchEventKinds}.
* @param isDirectory A boolean which is set to true if the input path is a directory, false otherwise.
* @param count Number of times the same event occurred.
* @throws java.lang.Exception if a listener throw an exception.
*/
protected void firePathChanged(Path target, WatchEvent.Kind kind, boolean isDirectory, int count) throws Exception {
final PathChangedEvent evt = new PathChangedEvent(this, target, kind, isDirectory, count);
Exception traced = null;
for (PathChangeListener l : listeners.getListeners(PathChangeListener.class)) {
try {
l.pathChanged(evt);
} catch (Exception e) {
if (traced == null) {
traced = e;
} else {
traced.addSuppressed(e);
}
}
}
if (traced != null) {
throw traced;
}
}
}