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.ClasspathURLConverter;
019
020import java.io.File;
021import java.io.IOException;
022import java.net.URISyntaxException;
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     * Converts a URL with protocol "file" to a File instance.
106     *
107     * @since 5.2.0
108     */
109    public static File toFileFromFileProtocolURL(URL url)
110    {
111        assert url != null;
112
113        if (!url.getProtocol().equals("file"))
114            throw new IllegalArgumentException(String.format("URL %s does not use the 'file' protocol.", url));
115
116        // http://weblogs.java.net/blog/kohsuke/archive/2007/04/how_to_convert.html
117
118        try
119        {
120            return new File(url.toURI());
121        } catch (URISyntaxException ex)
122        {
123            return new File(url.getPath());
124        }
125    }
126
127    /**
128     * Stores a new URL into the tracker, or returns the previous time stamp for a previously added URL. Filters out all
129     * non-file URLs.
130     * 
131     * @param url
132     *            of the resource to add, or null if not known
133     * @return the current timestamp for the URL (possibly rounded off for granularity reasons), or 0 if the URL is
134     *         null
135     */
136    public long add(URL url)
137    {
138        if (url == null)
139            return 0;
140
141        URL converted = classpathURLConverter.convert(url);
142
143        if (!converted.getProtocol().equals("file"))
144            return timestampForNonFileURL(converted);
145
146        File resourceFile = toFileFromFileProtocolURL(converted);
147
148        if (fileToTimestamp.containsKey(resourceFile))
149            return fileToTimestamp.get(resourceFile);
150
151        long timestamp = readTimestamp(resourceFile);
152
153        // A quick and imperfect fix for TAPESTRY-1918. When a file
154        // is added, add the directory containing the file as well.
155
156        fileToTimestamp.put(resourceFile, timestamp);
157
158        if (trackFolderChanges)
159        {
160            File dir = resourceFile.getParentFile();
161
162            if (!fileToTimestamp.containsKey(dir))
163            {
164                long dirTimestamp = readTimestamp(dir);
165                fileToTimestamp.put(dir, dirTimestamp);
166            }
167        }
168
169        return timestamp;
170    }
171
172    private long timestampForNonFileURL(URL url)
173    {
174        long timestamp;
175
176        try
177        {
178            timestamp = url.openConnection().getLastModified();
179        }
180        catch (IOException ex)
181        {
182            throw new RuntimeException(ex);
183        }
184
185        return applyGranularity(timestamp);
186    }
187
188    /**
189     * Clears all URL and timestamp data stored in the tracker.
190     */
191    public void clear()
192    {
193        fileToTimestamp.clear();
194    }
195
196    /**
197     * Re-acquires the last updated timestamp for each URL and returns true if any timestamp has changed.
198     */
199    public boolean containsChanges()
200    {
201        boolean result = false;
202
203        // This code would be highly suspect if this method was expected to be invoked
204        // concurrently, but CheckForUpdatesFilter ensures that it will be invoked
205        // synchronously.
206
207        for (Map.Entry<File, Long> entry : fileToTimestamp.entrySet())
208        {
209            long newTimestamp = readTimestamp(entry.getKey());
210            long current = entry.getValue();
211
212            if (current == newTimestamp)
213                continue;
214
215            result = true;
216            entry.setValue(newTimestamp);
217        }
218
219        return result;
220    }
221
222    /**
223     * Returns the time that the specified file was last modified, possibly rounded down to the nearest second.
224     */
225    private long readTimestamp(File file)
226    {
227        if (!file.exists())
228            return FILE_DOES_NOT_EXIST_TIMESTAMP;
229
230        return applyGranularity(file.lastModified());
231    }
232
233    private long applyGranularity(long timestamp)
234    {
235        // For coarse granularity (accurate only to the last second), remove the milliseconds since
236        // the last full second. This is for compatibility with client HTTP requests, which
237        // are only accurate to one second. The extra level of detail creates false positives
238        // for changes, and undermines HTTP response caching in the client.
239
240        if (granularitySeconds)
241            return timestamp - (timestamp % 1000);
242
243        return timestamp;
244    }
245
246    /**
247     * Needed for testing; changes file timestamps so that a change will be detected by {@link #containsChanges()}.
248     */
249    public void forceChange()
250    {
251        for (Map.Entry<File, Long> e : fileToTimestamp.entrySet())
252        {
253            e.setValue(0l);
254        }
255    }
256
257    /**
258     * Needed for testing.
259     */
260    int trackedFileCount()
261    {
262        return fileToTimestamp.size();
263    }
264
265}