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