001// Copyright 2006, 2007, 2008, 2010 The Apache Software Foundation
002//
003// Licensed under the Apache License, Version 2.0 (the "License");
004// you may not use this file except in compliance with the License.
005// You may obtain a copy of the License at
006//
007// http://www.apache.org/licenses/LICENSE-2.0
008//
009// Unless required by applicable law or agreed to in writing, software
010// distributed under the License is distributed on an "AS IS" BASIS,
011// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
012// See the License for the specific language governing permissions and
013// limitations under the License.
014
015package org.apache.tapestry5.ioc.internal.util;
016
017import org.apache.tapestry5.ioc.internal.services.ClasspathURLConverterImpl;
018import org.apache.tapestry5.ioc.services.ClassFabUtils;
019import org.apache.tapestry5.ioc.services.ClasspathURLConverter;
020
021import java.io.File;
022import java.io.IOException;
023import java.net.URL;
024import java.util.Map;
025
026/**
027 * Given a (growing) set of URLs, can periodically check to see if any of the underlying resources has changed. This
028 * class is capable of using either millisecond-level granularity or second-level granularity. Millisecond-level
029 * granularity is used by default. Second-level granularity is provided for compatibility with browsers vis-a-vis
030 * resource caching -- that's how granular they get with their "If-Modified-Since", "Last-Modified" and "Expires"
031 * headers.
032 */
033public class URLChangeTracker
034{
035    private static final long FILE_DOES_NOT_EXIST_TIMESTAMP = -1L;
036
037    private final Map<File, Long> fileToTimestamp = CollectionFactory.newConcurrentMap();
038
039    private final boolean granularitySeconds;
040
041    private final boolean trackFolderChanges;
042
043    private final ClasspathURLConverter classpathURLConverter;
044
045    public static final ClasspathURLConverter DEFAULT_CONVERTER = new ClasspathURLConverterImpl();
046
047    /**
048     * Creates a tracker using the default (does nothing) URL converter, with default (millisecond)
049     * granularity and folder tracking disabled.
050     * 
051     * @since 5.2.1
052     */
053    public URLChangeTracker()
054    {
055        this(DEFAULT_CONVERTER, false, false);
056    }
057
058    /**
059     * Creates a new URL change tracker with millisecond-level granularity and folder checking enabled.
060     * 
061     * @param classpathURLConverter
062     *            used to convert URLs from one protocol to another
063     */
064    public URLChangeTracker(ClasspathURLConverter classpathURLConverter)
065    {
066        this(classpathURLConverter, false);
067
068    }
069
070    /**
071     * Creates a new URL change tracker, using either millisecond-level granularity or second-level granularity and
072     * folder checking enabled.
073     * 
074     * @param classpathURLConverter
075     *            used to convert URLs from one protocol to another
076     * @param granularitySeconds
077     *            whether or not to use second granularity (as opposed to millisecond granularity)
078     */
079    public URLChangeTracker(ClasspathURLConverter classpathURLConverter, boolean granularitySeconds)
080    {
081        this(classpathURLConverter, granularitySeconds, true);
082    }
083
084    /**
085     * Creates a new URL change tracker, using either millisecond-level granularity or second-level granularity.
086     * 
087     * @param classpathURLConverter
088     *            used to convert URLs from one protocol to another
089     * @param granularitySeconds
090     *            whether or not to use second granularity (as opposed to millisecond granularity)
091     * @param trackFolderChanges
092     *            if true, then adding a file URL will also track the folder containing the file (this
093     *            is useful when concerned about additions to a folder)
094     * @since 5.2.1
095     */
096    public URLChangeTracker(ClasspathURLConverter classpathURLConverter, boolean granularitySeconds,
097            boolean trackFolderChanges)
098    {
099        this.granularitySeconds = granularitySeconds;
100        this.classpathURLConverter = classpathURLConverter;
101        this.trackFolderChanges = trackFolderChanges;
102    }
103
104    /**
105     * Stores a new URL into the tracker, or returns the previous time stamp for a previously added URL. Filters out all
106     * non-file URLs.
107     * 
108     * @param url
109     *            of the resource to add, or null if not known
110     * @return the current timestamp for the URL (possibly rounded off for granularity reasons), or 0 if the URL is
111     *         null
112     */
113    public long add(URL url)
114    {
115        if (url == null)
116            return 0;
117
118        URL converted = classpathURLConverter.convert(url);
119
120        if (!converted.getProtocol().equals("file"))
121            return timestampForNonFileURL(converted);
122
123        File resourceFile = ClassFabUtils.toFileFromFileProtocolURL(converted);
124
125        if (fileToTimestamp.containsKey(resourceFile))
126            return fileToTimestamp.get(resourceFile);
127
128        long timestamp = readTimestamp(resourceFile);
129
130        // A quick and imperfect fix for TAPESTRY-1918. When a file
131        // is added, add the directory containing the file as well.
132
133        fileToTimestamp.put(resourceFile, timestamp);
134
135        if (trackFolderChanges)
136        {
137            File dir = resourceFile.getParentFile();
138
139            if (!fileToTimestamp.containsKey(dir))
140            {
141                long dirTimestamp = readTimestamp(dir);
142                fileToTimestamp.put(dir, dirTimestamp);
143            }
144        }
145
146        return timestamp;
147    }
148
149    private long timestampForNonFileURL(URL url)
150    {
151        long timestamp;
152
153        try
154        {
155            timestamp = url.openConnection().getLastModified();
156        }
157        catch (IOException ex)
158        {
159            throw new RuntimeException(ex);
160        }
161
162        return applyGranularity(timestamp);
163    }
164
165    /**
166     * Clears all URL and timestamp data stored in the tracker.
167     */
168    public void clear()
169    {
170        fileToTimestamp.clear();
171    }
172
173    /**
174     * Re-acquires the last updated timestamp for each URL and returns true if any timestamp has changed.
175     */
176    public boolean containsChanges()
177    {
178        boolean result = false;
179
180        // This code would be highly suspect if this method was expected to be invoked
181        // concurrently, but CheckForUpdatesFilter ensures that it will be invoked
182        // synchronously.
183
184        for (Map.Entry<File, Long> entry : fileToTimestamp.entrySet())
185        {
186            long newTimestamp = readTimestamp(entry.getKey());
187            long current = entry.getValue();
188
189            if (current == newTimestamp)
190                continue;
191
192            result = true;
193            entry.setValue(newTimestamp);
194        }
195
196        return result;
197    }
198
199    /**
200     * Returns the time that the specified file was last modified, possibly rounded down to the nearest second.
201     */
202    private long readTimestamp(File file)
203    {
204        if (!file.exists())
205            return FILE_DOES_NOT_EXIST_TIMESTAMP;
206
207        return applyGranularity(file.lastModified());
208    }
209
210    private long applyGranularity(long timestamp)
211    {
212        // For coarse granularity (accurate only to the last second), remove the milliseconds since
213        // the last full second. This is for compatibility with client HTTP requests, which
214        // are only accurate to one second. The extra level of detail creates false positives
215        // for changes, and undermines HTTP response caching in the client.
216
217        if (granularitySeconds)
218            return timestamp - (timestamp % 1000);
219
220        return timestamp;
221    }
222
223    /**
224     * Needed for testing; changes file timestamps so that a change will be detected by {@link #containsChanges()}.
225     */
226    public void forceChange()
227    {
228        for (Map.Entry<File, Long> e : fileToTimestamp.entrySet())
229        {
230            e.setValue(0l);
231        }
232    }
233
234    /**
235     * Needed for testing.
236     */
237    int trackedFileCount()
238    {
239        return fileToTimestamp.size();
240    }
241
242}