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