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