001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.io;
003
004import java.awt.image.BufferedImage;
005import java.io.File;
006import java.io.RandomAccessFile;
007import java.math.BigInteger;
008import java.nio.charset.StandardCharsets;
009import java.security.MessageDigest;
010import java.util.Iterator;
011import java.util.Set;
012import java.util.TreeMap;
013
014import javax.imageio.ImageIO;
015
016import org.openstreetmap.josm.Main;
017import org.openstreetmap.josm.tools.ImageProvider;
018
019/**
020 * Use this class if you want to cache a lot of files that shouldn't be kept in memory. You can
021 * specify how much data should be stored and after which date the files should be expired.
022 * This works on a last-access basis, so files get deleted after they haven't been used for x days.
023 * You can turn this off by calling setUpdateModTime(false). Files get deleted on a first-in-first-out
024 * basis.
025 * @author xeen
026 *
027 */
028public class CacheFiles {
029    /**
030     * Common expirey dates
031     */
032    public static final int EXPIRE_NEVER = -1;
033    public static final int EXPIRE_DAILY = 60 * 60 * 24;
034    public static final int EXPIRE_WEEKLY = EXPIRE_DAILY * 7;
035    public static final int EXPIRE_MONTHLY = EXPIRE_WEEKLY * 4;
036
037    private final File dir;
038    private final String ident;
039    private final boolean enabled;
040
041    private long expire;  // in seconds
042    private long maxsize; // in megabytes
043    private boolean updateModTime = true;
044
045    // If the cache is full, we don't want to delete just one file
046    private static final int CLEANUP_TRESHOLD = 20;
047    // We don't want to clean after every file-write
048    private static final int CLEANUP_INTERVAL = 5;
049    // Stores how many files have been written
050    private int writes = 0;
051
052    /**
053     * Creates a new cache class. The ident will be used to store the files on disk and to save
054     * expire/space settings. Set plugin state to <code>true</code>.
055     * @param ident cache identifier
056     */
057    public CacheFiles(String ident) {
058        this(ident, true);
059    }
060
061    /**
062     * Creates a new cache class. The ident will be used to store the files on disk and to save
063     * expire/space settings.
064     * @param ident cache identifier
065     * @param isPlugin Whether this is a plugin or not (changes cache path)
066     */
067    public CacheFiles(String ident, boolean isPlugin) {
068        String pref = isPlugin ?
069                Main.pref.getPluginsDirectory().getPath() + File.separator + "cache" :
070                Main.pref.getCacheDirectory().getPath();
071
072        boolean dir_writeable;
073        this.ident = ident;
074        String cacheDir = Main.pref.get("cache." + ident + "." + "path", pref + File.separator + ident + File.separator);
075        this.dir = new File(cacheDir);
076        try {
077            this.dir.mkdirs();
078            dir_writeable = true;
079        } catch(Exception e) {
080            // We have no access to this directory, so don't do anything
081            dir_writeable = false;
082        }
083        this.enabled = dir_writeable;
084        this.expire = Main.pref.getLong("cache." + ident + "." + "expire", EXPIRE_DAILY);
085        if(this.expire < 0) {
086            this.expire = CacheFiles.EXPIRE_NEVER;
087        }
088        this.maxsize = Main.pref.getLong("cache." + ident + "." + "maxsize", 50);
089        if(this.maxsize < 0) {
090            this.maxsize = -1;
091        }
092    }
093
094    /**
095     * Loads the data for the given ident as an byte array. Returns null if data not available.
096     * @param ident cache identifier
097     * @return stored data
098     */
099    public byte[] getData(String ident) {
100        if(!enabled) return null;
101        try {
102            File data = getPath(ident);
103            if(!data.exists())
104                return null;
105
106            if(isExpired(data)) {
107                data.delete();
108                return null;
109            }
110
111            // Update last mod time so we don't expire recently used data
112            if(updateModTime) {
113                data.setLastModified(System.currentTimeMillis());
114            }
115
116            byte[] bytes = new byte[(int) data.length()];
117            try (RandomAccessFile raf = new RandomAccessFile(data, "r")) {
118                raf.readFully(bytes);
119            }
120            return bytes;
121        } catch (Exception e) {
122            Main.warn(e);
123        }
124        return null;
125    }
126
127    /**
128     * Writes an byte-array to disk
129     * @param ident cache identifier
130     * @param data data to store
131     */
132    public void saveData(String ident, byte[] data) {
133        if(!enabled) return;
134        try {
135            File f = getPath(ident);
136            if (f.exists()) {
137                f.delete();
138            }
139            // rws also updates the file meta-data, i.e. last mod time
140            try (RandomAccessFile raf = new RandomAccessFile(f, "rws")) {
141                raf.write(data);
142            }
143        } catch (Exception e) {
144            Main.warn(e);
145        }
146
147        writes++;
148        checkCleanUp();
149    }
150
151    /**
152     * Loads the data for the given ident as an image. If no image is found, null is returned
153     * @param ident cache identifier
154     * @return BufferedImage or null
155     */
156    public BufferedImage getImg(String ident) {
157        if(!enabled) return null;
158        try {
159            File img = getPath(ident, "png");
160            if(!img.exists())
161                return null;
162
163            if(isExpired(img)) {
164                img.delete();
165                return null;
166            }
167            // Update last mod time so we don't expire recently used images
168            if(updateModTime) {
169                img.setLastModified(System.currentTimeMillis());
170            }
171            return ImageProvider.read(img, false, false);
172        } catch (Exception e) {
173            Main.warn(e);
174        }
175        return null;
176    }
177
178    /**
179     * Saves a given image and ident to the cache
180     * @param ident cache identifier
181     * @param image imaga data for storage
182     */
183    public void saveImg(String ident, BufferedImage image) {
184        if (!enabled) return;
185        try {
186            ImageIO.write(image, "png", getPath(ident, "png"));
187        } catch (Exception e) {
188            Main.warn(e);
189        }
190
191        writes++;
192        checkCleanUp();
193    }
194
195    /**
196     * Sets the amount of time data is stored before it gets expired
197     * @param amount of time in seconds
198     * @param force will also write it to the preferences
199     */
200    public void setExpire(int amount, boolean force) {
201        String key = "cache." + ident + "." + "expire";
202        if(!Main.pref.get(key).isEmpty() && !force)
203            return;
204
205        this.expire = amount > 0 ? amount : EXPIRE_NEVER;
206        Main.pref.putLong(key, this.expire);
207    }
208
209    /**
210     * Sets the amount of data stored in the cache
211     * @param amount in Megabytes
212     * @param force will also write it to the preferences
213     */
214    public void setMaxSize(int amount, boolean force) {
215        String key = "cache." + ident + "." + "maxsize";
216        if(!Main.pref.get(key).isEmpty() && !force)
217            return;
218
219        this.maxsize = amount > 0 ? amount : -1;
220        Main.pref.putLong(key, this.maxsize);
221    }
222
223    /**
224     * Call this with <code>true</code> to update the last modification time when a file it is read.
225     * Call this with <code>false</code> to not update the last modification time when a file is read.
226     * @param to update state
227     */
228    public void setUpdateModTime(boolean to) {
229        updateModTime = to;
230    }
231
232    /**
233     * Checks if a clean up is needed and will do so if necessary
234     */
235    public void checkCleanUp() {
236        if(this.writes > CLEANUP_INTERVAL) {
237            cleanUp();
238        }
239    }
240
241    /**
242     * Performs a default clean up with the set values (deletes oldest files first)
243     */
244    public void cleanUp() {
245        if(!this.enabled || maxsize == -1) return;
246
247        TreeMap<Long, File> modtime = new TreeMap<>();
248        long dirsize = 0;
249
250        for(File f : dir.listFiles()) {
251            if(isExpired(f)) {
252                f.delete();
253            } else {
254                dirsize += f.length();
255                modtime.put(f.lastModified(), f);
256            }
257        }
258
259        if(dirsize < maxsize*1000*1000) return;
260
261        Set<Long> keySet = modtime.keySet();
262        Iterator<Long> it = keySet.iterator();
263        int i=0;
264        while (it.hasNext()) {
265            i++;
266            modtime.get(it.next()).delete();
267
268            // Delete a couple of files, then check again
269            if(i % CLEANUP_TRESHOLD == 0 && getDirSize() < maxsize)
270                return;
271        }
272        writes = 0;
273    }
274
275    public static final int CLEAN_ALL = 0;
276    public static final int CLEAN_SMALL_FILES = 1;
277    public static final int CLEAN_BY_DATE = 2;
278
279    /**
280     * Performs a non-default, specified clean up
281     * @param type any of the CLEAN_XX constants.
282     * @param size for CLEAN_SMALL_FILES: deletes all files smaller than (size) bytes
283     */
284    public void customCleanUp(int type, int size) {
285        switch(type) {
286        case CLEAN_ALL:
287            for(File f : dir.listFiles()) {
288                f.delete();
289            }
290            break;
291        case CLEAN_SMALL_FILES:
292            for(File f: dir.listFiles())
293                if(f.length() < size) {
294                    f.delete();
295                }
296            break;
297        case CLEAN_BY_DATE:
298            cleanUp();
299            break;
300        }
301    }
302
303    /**
304     * Calculates the size of the directory
305     * @return long Size of directory in bytes
306     */
307    private long getDirSize() {
308        if(!enabled) return -1;
309        long dirsize = 0;
310
311        for(File f : this.dir.listFiles()) {
312            dirsize += f.length();
313        }
314        return dirsize;
315    }
316
317    /**
318     * Returns a short and unique file name for a given long identifier
319     * @return String short filename
320     */
321    private static String getUniqueFilename(String ident) {
322        try {
323            MessageDigest md = MessageDigest.getInstance("MD5");
324            BigInteger number = new BigInteger(1, md.digest(ident.getBytes(StandardCharsets.UTF_8)));
325            return number.toString(16);
326        } catch(Exception e) {
327            // Fall back. Remove unsuitable characters and some random ones to shrink down path length.
328            // Limit it to 70 characters, that leaves about 190 for the path on Windows/NTFS
329            ident = ident.replaceAll("[^a-zA-Z0-9]", "");
330            ident = ident.replaceAll("[acegikmoqsuwy]", "");
331            return ident.substring(ident.length() - 70);
332        }
333    }
334
335    /**
336     * Gets file path for ident with customizable file-ending
337     * @param ident cache identifier
338     * @param ending file extension
339     * @return file structure
340     */
341    private File getPath(String ident, String ending) {
342        return new File(dir, getUniqueFilename(ident) + "." + ending);
343    }
344
345    /**
346     * Gets file path for ident
347     * @param ident cache identifier
348     * @return file structure
349     */
350    private File getPath(String ident) {
351        return new File(dir, getUniqueFilename(ident));
352    }
353
354    /**
355     * Checks whether a given file is expired
356     * @param file file description structure
357     * @return expired state
358     */
359    private boolean isExpired(File file) {
360        if(CacheFiles.EXPIRE_NEVER == this.expire)
361            return false;
362        return (file.lastModified() < (System.currentTimeMillis() - expire*1000));
363    }
364}