001/*
002 * Copyright 2012-2017 the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package org.springframework.boot.devtools.filewatch;
018
019import java.io.File;
020import java.io.FileFilter;
021import java.util.ArrayList;
022import java.util.Collection;
023import java.util.Collections;
024import java.util.HashMap;
025import java.util.LinkedHashMap;
026import java.util.LinkedHashSet;
027import java.util.List;
028import java.util.Map;
029import java.util.Set;
030import java.util.concurrent.atomic.AtomicInteger;
031
032import org.springframework.util.Assert;
033
034/**
035 * Watches specific folders for file changes.
036 *
037 * @author Andy Clement
038 * @author Phillip Webb
039 * @since 1.3.0
040 * @see FileChangeListener
041 */
042public class FileSystemWatcher {
043
044        private static final long DEFAULT_POLL_INTERVAL = 1000;
045
046        private static final long DEFAULT_QUIET_PERIOD = 400;
047
048        private final List<FileChangeListener> listeners = new ArrayList<FileChangeListener>();
049
050        private final boolean daemon;
051
052        private final long pollInterval;
053
054        private final long quietPeriod;
055
056        private final AtomicInteger remainingScans = new AtomicInteger(-1);
057
058        private final Map<File, FolderSnapshot> folders = new HashMap<File, FolderSnapshot>();
059
060        private Thread watchThread;
061
062        private FileFilter triggerFilter;
063
064        private final Object monitor = new Object();
065
066        /**
067         * Create a new {@link FileSystemWatcher} instance.
068         */
069        public FileSystemWatcher() {
070                this(true, DEFAULT_POLL_INTERVAL, DEFAULT_QUIET_PERIOD);
071        }
072
073        /**
074         * Create a new {@link FileSystemWatcher} instance.
075         * @param daemon if a daemon thread used to monitor changes
076         * @param pollInterval the amount of time to wait between checking for changes
077         * @param quietPeriod the amount of time required after a change has been detected to
078         * ensure that updates have completed
079         */
080        public FileSystemWatcher(boolean daemon, long pollInterval, long quietPeriod) {
081                Assert.isTrue(pollInterval > 0, "PollInterval must be positive");
082                Assert.isTrue(quietPeriod > 0, "QuietPeriod must be positive");
083                Assert.isTrue(pollInterval > quietPeriod,
084                                "PollInterval must be greater than QuietPeriod");
085                this.daemon = daemon;
086                this.pollInterval = pollInterval;
087                this.quietPeriod = quietPeriod;
088        }
089
090        /**
091         * Add listener for file change events. Cannot be called after the watcher has been
092         * {@link #start() started}.
093         * @param fileChangeListener the listener to add
094         */
095        public void addListener(FileChangeListener fileChangeListener) {
096                Assert.notNull(fileChangeListener, "FileChangeListener must not be null");
097                synchronized (this.monitor) {
098                        checkNotStarted();
099                        this.listeners.add(fileChangeListener);
100                }
101        }
102
103        /**
104         * Add source folders to monitor. Cannot be called after the watcher has been
105         * {@link #start() started}.
106         * @param folders the folders to monitor
107         */
108        public void addSourceFolders(Iterable<File> folders) {
109                Assert.notNull(folders, "Folders must not be null");
110                synchronized (this.monitor) {
111                        for (File folder : folders) {
112                                addSourceFolder(folder);
113                        }
114                }
115        }
116
117        /**
118         * Add a source folder to monitor. Cannot be called after the watcher has been
119         * {@link #start() started}.
120         * @param folder the folder to monitor
121         */
122        public void addSourceFolder(File folder) {
123                Assert.notNull(folder, "Folder must not be null");
124                Assert.isTrue(folder.isDirectory(),
125                                "Folder '" + folder + "' must exist and must" + " be a directory");
126                synchronized (this.monitor) {
127                        checkNotStarted();
128                        this.folders.put(folder, null);
129                }
130        }
131
132        /**
133         * Set an optional {@link FileFilter} used to limit the files that trigger a change.
134         * @param triggerFilter a trigger filter or null
135         */
136        public void setTriggerFilter(FileFilter triggerFilter) {
137                synchronized (this.monitor) {
138                        this.triggerFilter = triggerFilter;
139                }
140        }
141
142        private void checkNotStarted() {
143                synchronized (this.monitor) {
144                        Assert.state(this.watchThread == null, "FileSystemWatcher already started");
145                }
146        }
147
148        /**
149         * Start monitoring the source folder for changes.
150         */
151        public void start() {
152                synchronized (this.monitor) {
153                        saveInitialSnapshots();
154                        if (this.watchThread == null) {
155                                Map<File, FolderSnapshot> localFolders = new HashMap<File, FolderSnapshot>();
156                                localFolders.putAll(this.folders);
157                                this.watchThread = new Thread(new Watcher(this.remainingScans,
158                                                new ArrayList<FileChangeListener>(this.listeners),
159                                                this.triggerFilter, this.pollInterval, this.quietPeriod,
160                                                localFolders));
161                                this.watchThread.setName("File Watcher");
162                                this.watchThread.setDaemon(this.daemon);
163                                this.watchThread.start();
164                        }
165                }
166        }
167
168        private void saveInitialSnapshots() {
169                for (File folder : this.folders.keySet()) {
170                        this.folders.put(folder, new FolderSnapshot(folder));
171                }
172        }
173
174        /**
175         * Stop monitoring the source folders.
176         */
177        public void stop() {
178                stopAfter(0);
179        }
180
181        /**
182         * Stop monitoring the source folders.
183         * @param remainingScans the number of remaining scans
184         */
185        void stopAfter(int remainingScans) {
186                Thread thread = null;
187                synchronized (this.monitor) {
188                        thread = this.watchThread;
189                        if (thread != null) {
190                                this.remainingScans.set(remainingScans);
191                                if (remainingScans <= 0) {
192                                        thread.interrupt();
193                                }
194                        }
195                        this.watchThread = null;
196                }
197                if (thread != null && Thread.currentThread() != thread) {
198                        try {
199                                thread.join();
200                        }
201                        catch (InterruptedException ex) {
202                                Thread.currentThread().interrupt();
203                        }
204                }
205        }
206
207        private static final class Watcher implements Runnable {
208
209                private final AtomicInteger remainingScans;
210
211                private final List<FileChangeListener> listeners;
212
213                private final FileFilter triggerFilter;
214
215                private final long pollInterval;
216
217                private final long quietPeriod;
218
219                private Map<File, FolderSnapshot> folders;
220
221                private Watcher(AtomicInteger remainingScans, List<FileChangeListener> listeners,
222                                FileFilter triggerFilter, long pollInterval, long quietPeriod,
223                                Map<File, FolderSnapshot> folders) {
224                        this.remainingScans = remainingScans;
225                        this.listeners = listeners;
226                        this.triggerFilter = triggerFilter;
227                        this.pollInterval = pollInterval;
228                        this.quietPeriod = quietPeriod;
229                        this.folders = folders;
230                }
231
232                @Override
233                public void run() {
234                        int remainingScans = this.remainingScans.get();
235                        while (remainingScans > 0 || remainingScans == -1) {
236                                try {
237                                        if (remainingScans > 0) {
238                                                this.remainingScans.decrementAndGet();
239                                        }
240                                        scan();
241                                }
242                                catch (InterruptedException ex) {
243                                        Thread.currentThread().interrupt();
244                                }
245                                remainingScans = this.remainingScans.get();
246                        }
247                };
248
249                private void scan() throws InterruptedException {
250                        Thread.sleep(this.pollInterval - this.quietPeriod);
251                        Map<File, FolderSnapshot> previous;
252                        Map<File, FolderSnapshot> current = this.folders;
253                        do {
254                                previous = current;
255                                current = getCurrentSnapshots();
256                                Thread.sleep(this.quietPeriod);
257                        }
258                        while (isDifferent(previous, current));
259                        if (isDifferent(this.folders, current)) {
260                                updateSnapshots(current.values());
261                        }
262                }
263
264                private boolean isDifferent(Map<File, FolderSnapshot> previous,
265                                Map<File, FolderSnapshot> current) {
266                        if (!previous.keySet().equals(current.keySet())) {
267                                return true;
268                        }
269                        for (Map.Entry<File, FolderSnapshot> entry : previous.entrySet()) {
270                                FolderSnapshot previousFolder = entry.getValue();
271                                FolderSnapshot currentFolder = current.get(entry.getKey());
272                                if (!previousFolder.equals(currentFolder, this.triggerFilter)) {
273                                        return true;
274                                }
275                        }
276                        return false;
277                }
278
279                private Map<File, FolderSnapshot> getCurrentSnapshots() {
280                        Map<File, FolderSnapshot> snapshots = new LinkedHashMap<File, FolderSnapshot>();
281                        for (File folder : this.folders.keySet()) {
282                                snapshots.put(folder, new FolderSnapshot(folder));
283                        }
284                        return snapshots;
285                }
286
287                private void updateSnapshots(Collection<FolderSnapshot> snapshots) {
288                        Map<File, FolderSnapshot> updated = new LinkedHashMap<File, FolderSnapshot>();
289                        Set<ChangedFiles> changeSet = new LinkedHashSet<ChangedFiles>();
290                        for (FolderSnapshot snapshot : snapshots) {
291                                FolderSnapshot previous = this.folders.get(snapshot.getFolder());
292                                updated.put(snapshot.getFolder(), snapshot);
293                                ChangedFiles changedFiles = previous.getChangedFiles(snapshot,
294                                                this.triggerFilter);
295                                if (!changedFiles.getFiles().isEmpty()) {
296                                        changeSet.add(changedFiles);
297                                }
298                        }
299                        if (!changeSet.isEmpty()) {
300                                fireListeners(Collections.unmodifiableSet(changeSet));
301                        }
302                        this.folders = updated;
303                }
304
305                private void fireListeners(Set<ChangedFiles> changeSet) {
306                        for (FileChangeListener listener : this.listeners) {
307                                listener.onChange(changeSet);
308                        }
309                }
310
311        }
312
313}