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}