001/*
002 * This file is part of McIDAS-V
003 *
004 * Copyright 2007-2016
005 * Space Science and Engineering Center (SSEC)
006 * University of Wisconsin - Madison
007 * 1225 W. Dayton Street, Madison, WI 53706, USA
008 * https://www.ssec.wisc.edu/mcidas
009 * 
010 * All Rights Reserved
011 * 
012 * McIDAS-V is built on Unidata's IDV and SSEC's VisAD libraries, and
013 * some McIDAS-V source code is based on IDV and VisAD source code.  
014 * 
015 * McIDAS-V is free software; you can redistribute it and/or modify
016 * it under the terms of the GNU Lesser Public License as published by
017 * the Free Software Foundation; either version 3 of the License, or
018 * (at your option) any later version.
019 * 
020 * McIDAS-V is distributed in the hope that it will be useful,
021 * but WITHOUT ANY WARRANTY; without even the implied warranty of
022 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
023 * GNU Lesser Public License for more details.
024 * 
025 * You should have received a copy of the GNU Lesser Public License
026 * along with this program.  If not, see http://www.gnu.org/licenses.
027 */
028
029package edu.wisc.ssec.mcidasv.util.pathwatcher;
030
031import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
032import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE;
033import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
034import static java.nio.file.StandardWatchEventKinds.OVERFLOW;
035
036import static edu.wisc.ssec.mcidasv.util.CollectionHelpers.concurrentMap;
037import static edu.wisc.ssec.mcidasv.util.CollectionHelpers.concurrentSet;
038
039import java.io.IOException;
040
041import java.nio.file.FileSystem;
042import java.nio.file.FileSystems;
043import java.nio.file.Files;
044import java.nio.file.Path;
045import java.nio.file.PathMatcher;
046import java.nio.file.Paths;
047import java.nio.file.WatchEvent;
048import java.nio.file.WatchKey;
049import java.nio.file.WatchService;
050
051import java.util.Arrays;
052import java.util.Map;
053import java.util.Set;
054import java.util.concurrent.atomic.AtomicBoolean;
055import java.util.stream.Collectors;
056
057import org.slf4j.Logger;
058import org.slf4j.LoggerFactory;
059
060// Adapted from https://gist.github.com/hindol-viz/394ebc553673e2cd0699
061
062/**
063 * A simple class which can monitor files and notify interested parties
064 * (i.e. listeners) of file changes.
065 *
066 * This class is kept lean by only keeping methods that are actually being
067 * called.
068 */
069public class SimpleDirectoryWatchService implements DirectoryWatchService,
070        Runnable
071{
072
073    /** Logging object. */
074    private static final Logger logger =
075            LoggerFactory.getLogger(SimpleDirectoryWatchService.class);
076
077    /**
078     * {@code WatchService} used to monitor changes in various
079     * {@link Path Paths}.
080     */
081    private final WatchService mWatchService;
082
083    /** Whether or not this {@link DirectoryWatchService} is running. */
084    private final AtomicBoolean mIsRunning;
085
086    /**
087     * Mapping of monitoring {@literal "registration"} keys to the
088     * {@link Path} that it will be watching.
089     */
090    private final Map<WatchKey, Path> mWatchKeyToDirPathMap;
091
092    /**
093     * Mapping of {@link Path Paths} to the {@link Set} of
094     * {@link OnFileChangeListener OnFileChangeListeners} listening for
095     * changes to the associated {@code Path}.
096     */
097    private final Map<Path, Set<OnFileChangeListener>> mDirPathToListenersMap;
098
099    /**
100     * Mapping of {@link OnFileChangeListener OnFileChangeListeners} to the
101     * {@link Set} of patterns being used to observe changes in
102     * {@link Path Paths} of interest.
103     */
104    private final Map<OnFileChangeListener, Set<PathMatcher>>
105        mListenerToFilePatternsMap;
106
107    /**
108     * A simple no argument constructor for creating a
109     * {@code SimpleDirectoryWatchService}.
110     *
111     * @throws IOException If an I/O error occurs.
112     */
113    public SimpleDirectoryWatchService() throws IOException {
114        mWatchService = FileSystems.getDefault().newWatchService();
115        mIsRunning = new AtomicBoolean(false);
116        mWatchKeyToDirPathMap = concurrentMap();
117        mDirPathToListenersMap = concurrentMap();
118        mListenerToFilePatternsMap = concurrentMap();
119    }
120
121    /**
122     * Utility method used to make {@literal "valid"} casts of the given
123     * {@code event} to a specific type of {@link WatchEvent}.
124     *
125     * @param <T> Type to which {@code event} will be casted.
126     * @param event Event to cast.
127     *
128     * @return {@code event} casted to {@code WatchEvent<T>}.
129     */
130    @SuppressWarnings("unchecked")
131    private static <T> WatchEvent<T> cast(WatchEvent<?> event) {
132        return (WatchEvent<T>)event;
133    }
134
135    /**
136     * Returns a {@link PathMatcher} that performs {@literal "glob"} matches
137     * with the given {@code globPattern} against the {@code String}
138     * representation of {@link Path} objects.
139     *
140     * @param globPattern Pattern to match against. {@code null} or empty
141     *                    {@code String} values will be converted to {@code *}.
142     *
143     * @return Path matching object for the given {@code globPattern}.
144     *
145     * @throws IOException if there was a problem creating the
146     *                     {@code PathMatcher}.
147     */
148    private static PathMatcher matcherForGlobExpression(String globPattern)
149        throws IOException
150    {
151        if ((globPattern == null) || globPattern.isEmpty()) {
152            globPattern = "*";
153        }
154
155        return FileSystems.getDefault().getPathMatcher("glob:"+globPattern);
156    }
157
158    /**
159     * Check the given {@code input} {@link Path} against the given {@code
160     * pattern}.
161     *
162     * @param input Path to check.
163     * @param pattern Pattern to check against. Cannot be {@code null}.
164     *
165     * @return Whether or not {@code input} matches {@code pattern}.
166     */
167    public static boolean matches(Path input, PathMatcher pattern) {
168        return pattern.matches(input);
169    }
170
171    /**
172     * Check the given {@code input} {@link Path} against <i>all</i> of the
173     * specified {@code patterns}.
174     *
175     * @param input Path to check.
176     * @param patterns {@link Set} of patterns to attempt to match
177     *                 {@code input} against. Cannot be {@code null}.
178     *
179     * @return Whether or not {@code input} matches any of the given
180     *         {@code patterns}.
181     */
182    private static boolean matchesAny(Path input, Set<PathMatcher> patterns) {
183        for (PathMatcher pattern : patterns) {
184            if (matches(input, pattern)) {
185                return true;
186            }
187        }
188
189        return false;
190    }
191
192    /**
193     * Get the path associated with the given {@link WatchKey}.
194     *
195     * @param key {@code WatchKey} whose corresponding {@link Path} is being
196     *            requested.
197     *
198     * @return Either the correspond {@code Path} or {@code null}.
199     */
200    private Path getDirPath(WatchKey key) {
201        return mWatchKeyToDirPathMap.get(key);
202    }
203
204    /**
205     * Get the {@link OnFileChangeListener OnFileChangeListeners} associated
206     * with the given {@code path}.
207     *
208     * @param path Path whose listeners should be returned. Cannot be
209     *             {@code null}.
210     *
211     * @return Either the {@link Set} of listeners associated with {@code path}
212     *         or {@code null}.
213     */
214    private Set<OnFileChangeListener> getListeners(Path path) {
215        return mDirPathToListenersMap.get(path);
216    }
217
218    /**
219     * Get the {@link Set} of patterns associated with the given
220     * {@link OnFileChangeListener}.
221     *
222     * @param listener Listener of interest.
223     *
224     * @return Either the {@code Set} of patterns associated with
225     *         {@code listener} or {@code null}.
226     */
227    private Set<PathMatcher> getPatterns(OnFileChangeListener listener) {
228        return mListenerToFilePatternsMap.get(listener);
229    }
230
231    /**
232     * Get the {@link Path} associated with the given
233     * {@link OnFileChangeListener}.
234     *
235     * @param listener Listener whose path is requested.
236     *
237     * @return Either the {@code Path} associated with {@code listener} or
238     *         {@code null}.
239     */
240    private Path getDir(OnFileChangeListener listener) {
241
242        Set<Map.Entry<Path, Set<OnFileChangeListener>>> entries =
243                mDirPathToListenersMap.entrySet();
244
245        Path result = null;
246        for (Map.Entry<Path, Set<OnFileChangeListener>> entry : entries) {
247            Set<OnFileChangeListener> listeners = entry.getValue();
248            if (listeners.contains(listener)) {
249                result = entry.getKey();
250                break;
251            }
252        }
253
254        return result;
255    }
256
257    /**
258     * Get the monitoring {@literal "registration"} key associated with the
259     * given {@link Path}.
260     *
261     * @param dir {@code Path} whose {@link WatchKey} is requested.
262     *
263     * @return Either the {@code WatchKey} corresponding to {@code dir} or
264     *         {@code null}.
265     */
266    private WatchKey getWatchKey(Path dir) {
267        Set<Map.Entry<WatchKey, Path>> entries =
268            mWatchKeyToDirPathMap.entrySet();
269
270        WatchKey key = null;
271        for (Map.Entry<WatchKey, Path> entry : entries) {
272            if (entry.getValue().equals(dir)) {
273                key = entry.getKey();
274                break;
275            }
276        }
277
278        return key;
279    }
280
281    /**
282     * Get the {@link Set} of
283     * {@link OnFileChangeListener OnFileChangeListeners} that should be
284     * notified that {@code file} has changed.
285     *
286     * @param dir Directory containing {@code file}.
287     * @param file File that changed.
288     *
289     * @return {@code Set} of listeners that should be notified that
290     *         {@code file} has changed.
291     */
292    private Set<OnFileChangeListener> matchedListeners(Path dir, Path file) {
293        return getListeners(dir)
294                .stream()
295                .filter(listener -> matchesAny(file, getPatterns(listener)))
296                .collect(Collectors.toSet());
297    }
298
299    /**
300     * Method responsible for notifying listeners when a file matching their
301     * relevant pattern has changed.
302     *
303     * <p>Note: {@literal "change"} means one of:
304     * <ul>
305     *   <li>file creation</li>
306     *   <li>file removal</li>
307     *   <li>file contents changing</li>
308     * </ul></p>
309     *
310     * @param key {@link #mWatchService} {@literal "registration"} key for
311     *            one of the {@link Path Paths} being watched. Cannot be
312     *            {@code null}.
313     *
314     * @see #run()
315     */
316    private void notifyListeners(WatchKey key) {
317        for (WatchEvent<?> event : key.pollEvents()) {
318            WatchEvent.Kind eventKind = event.kind();
319
320            // Overflow occurs when the watch event queue is overflown
321            // with events.
322            if (eventKind.equals(OVERFLOW)) {
323                // TODO: Notify all listeners.
324                return;
325            }
326
327            WatchEvent<Path> pathEvent = cast(event);
328            Path file = pathEvent.context();
329            String completePath =  getDirPath(key).resolve(file).toString();
330
331            if (eventKind.equals(ENTRY_CREATE)) {
332                matchedListeners(getDirPath(key), file)
333                    .forEach(l -> l.onFileCreate(completePath));
334            } else if (eventKind.equals(ENTRY_MODIFY)) {
335                matchedListeners(getDirPath(key), file)
336                    .forEach(l -> l.onFileModify(completePath));
337            } else if (eventKind.equals(ENTRY_DELETE)) {
338                matchedListeners(getDirPath(key), file)
339                    .forEach(l -> l.onFileDelete(completePath));
340            }
341        }
342    }
343
344    /**
345     * {@inheritDoc}
346     */
347    @Override public void register(OnFileChangeListener listener,
348                                   String dirPath, String... globPatterns)
349            throws IOException
350    {
351        Path dir = Paths.get(dirPath);
352
353        if (!Files.isDirectory(dir)) {
354            throw new IllegalArgumentException(dirPath + " not a directory.");
355        }
356
357        if (!mDirPathToListenersMap.containsKey(dir)) {
358            // May throw
359            WatchKey key = dir.register(
360                mWatchService, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE
361            );
362
363            mWatchKeyToDirPathMap.put(key, dir);
364            mDirPathToListenersMap.put(dir, concurrentSet());
365        }
366
367        getListeners(dir).add(listener);
368
369        Set<PathMatcher> patterns = concurrentSet();
370
371        for (String globPattern : globPatterns) {
372            patterns.add(matcherForGlobExpression(globPattern));
373        }
374
375        if (patterns.isEmpty()) {
376            // Match everything if no filter is found
377            patterns.add(matcherForGlobExpression("*"));
378        }
379
380        mListenerToFilePatternsMap.put(listener, patterns);
381
382        logger.trace("Watching files matching {} under '{}' for changes",
383                Arrays.toString(globPatterns), dirPath);
384    }
385
386    /**
387     * {@inheritDoc}
388     */
389    public void unregister(OnFileChangeListener listener) {
390        Path dir = getDir(listener);
391
392        mDirPathToListenersMap.get(dir).remove(listener);
393
394        // is this step truly needed?
395        if (mDirPathToListenersMap.get(dir).isEmpty()) {
396            mDirPathToListenersMap.remove(dir);
397        }
398
399        mListenerToFilePatternsMap.remove(listener);
400
401        WatchKey key = getWatchKey(dir);
402        if (key != null) {
403            mWatchKeyToDirPathMap.remove(key);
404            key.cancel();
405        }
406        logger.trace("listener unregistered");
407    }
408
409    /**
410     * {@inheritDoc}
411     */
412    @Override public void unregisterAll() {
413        // can't simply clear the key->dir map; need to cancel
414        mWatchKeyToDirPathMap.keySet().forEach(WatchKey::cancel);
415
416        mWatchKeyToDirPathMap.clear();
417        mDirPathToListenersMap.clear();
418        mListenerToFilePatternsMap.clear();
419    }
420
421    /**
422     * Start this {@code SimpleDirectoryWatchService} instance by spawning a
423     * new thread.
424     *
425     * @see #stop()
426     */
427    @Override public void start() {
428        if (mIsRunning.compareAndSet(false, true)) {
429            String name = DirectoryWatchService.class.getSimpleName();
430            Thread runnerThread = new Thread(this, name);
431            runnerThread.start();
432        }
433    }
434
435    /**
436     * Stop this {@code SimpleDirectoryWatchService} thread.
437     *
438     * <p>The killing happens lazily, giving the running thread an opportunity
439     * to finish the work at hand.</p>
440     *
441     * @see #start()
442     */
443    @Override public void stop() {
444        // Kill thread lazily
445        mIsRunning.set(false);
446    }
447
448    /**
449     * {@inheritDoc}
450     */
451    @Override public boolean isRunning() {
452        return mIsRunning.get();
453    }
454
455    /**
456     * {@inheritDoc}
457     */
458    @Override public void run() {
459        logger.info("Starting file watcher service.");
460
461        while (mIsRunning.get()) {
462            WatchKey key;
463            try {
464                key = mWatchService.take();
465            } catch (InterruptedException e) {
466                logger.trace("{} service interrupted.",
467                        DirectoryWatchService.class.getSimpleName());
468                break;
469            }
470
471            if (null == getDirPath(key)) {
472                logger.error("Watch key not recognized.");
473                continue;
474            }
475
476            notifyListeners(key);
477
478            // Reset key to allow further events for this key to be processed.
479            boolean valid = key.reset();
480            if (!valid) {
481                mWatchKeyToDirPathMap.remove(key);
482                if (mWatchKeyToDirPathMap.isEmpty()) {
483                    break;
484                }
485            }
486        }
487
488        mIsRunning.set(false);
489        logger.trace("Stopping file watcher service.");
490    }
491}