| 1 | | // License: GPL. For details, see LICENSE file. |
| 2 | | package org.openstreetmap.josm.io; |
| 3 | | |
| 4 | | import java.awt.image.BufferedImage; |
| 5 | | import java.io.File; |
| 6 | | import java.io.RandomAccessFile; |
| 7 | | import java.math.BigInteger; |
| 8 | | import java.nio.charset.StandardCharsets; |
| 9 | | import java.security.MessageDigest; |
| 10 | | import java.util.Iterator; |
| 11 | | import java.util.Set; |
| 12 | | import java.util.SortedMap; |
| 13 | | import java.util.TreeMap; |
| 14 | | |
| 15 | | import javax.imageio.ImageIO; |
| 16 | | |
| 17 | | import org.openstreetmap.josm.Main; |
| 18 | | import org.openstreetmap.josm.tools.ImageProvider; |
| 19 | | |
| 20 | | /** |
| 21 | | * Use this class if you want to cache a lot of files that shouldn't be kept in memory. You can |
| 22 | | * specify how much data should be stored and after which date the files should be expired. |
| 23 | | * This works on a last-access basis, so files get deleted after they haven't been used for x days. |
| 24 | | * You can turn this off by calling setUpdateModTime(false). Files get deleted on a first-in-first-out |
| 25 | | * basis. |
| 26 | | * @author xeen |
| 27 | | * |
| 28 | | */ |
| 29 | | public class CacheFiles { |
| 30 | | /** |
| 31 | | * Common expirey dates |
| 32 | | */ |
| 33 | | public static final int EXPIRE_NEVER = -1; |
| 34 | | public static final int EXPIRE_DAILY = 60 * 60 * 24; |
| 35 | | public static final int EXPIRE_WEEKLY = EXPIRE_DAILY * 7; |
| 36 | | public static final int EXPIRE_MONTHLY = EXPIRE_WEEKLY * 4; |
| 37 | | |
| 38 | | private final File dir; |
| 39 | | private final String ident; |
| 40 | | private final boolean enabled; |
| 41 | | |
| 42 | | private long expire; // in seconds |
| 43 | | private long maxsize; // in megabytes |
| 44 | | private boolean updateModTime = true; |
| 45 | | |
| 46 | | // If the cache is full, we don't want to delete just one file |
| 47 | | private static final int CLEANUP_TRESHOLD = 20; |
| 48 | | // We don't want to clean after every file-write |
| 49 | | private static final int CLEANUP_INTERVAL = 5; |
| 50 | | // Stores how many files have been written |
| 51 | | private int writes = 0; |
| 52 | | |
| 53 | | /** |
| 54 | | * Creates a new cache class. The ident will be used to store the files on disk and to save |
| 55 | | * expire/space settings. Set plugin state to <code>true</code>. |
| 56 | | * @param ident cache identifier |
| 57 | | */ |
| 58 | | public CacheFiles(String ident) { |
| 59 | | this(ident, true); |
| 60 | | } |
| 61 | | |
| 62 | | /** |
| 63 | | * Creates a new cache class. The ident will be used to store the files on disk and to save |
| 64 | | * expire/space settings. |
| 65 | | * @param ident cache identifier |
| 66 | | * @param isPlugin Whether this is a plugin or not (changes cache path) |
| 67 | | */ |
| 68 | | public CacheFiles(String ident, boolean isPlugin) { |
| 69 | | String pref = isPlugin ? |
| 70 | | Main.pref.getPluginsDirectory().getPath() + File.separator + "cache" : |
| 71 | | Main.pref.getCacheDirectory().getPath(); |
| 72 | | |
| 73 | | boolean dir_writeable; |
| 74 | | this.ident = ident; |
| 75 | | String cacheDir = Main.pref.get("cache." + ident + "." + "path", pref + File.separator + ident + File.separator); |
| 76 | | this.dir = new File(cacheDir); |
| 77 | | try { |
| 78 | | this.dir.mkdirs(); |
| 79 | | dir_writeable = true; |
| 80 | | } catch (Exception e) { |
| 81 | | // We have no access to this directory, so don't do anything |
| 82 | | dir_writeable = false; |
| 83 | | } |
| 84 | | this.enabled = dir_writeable; |
| 85 | | this.expire = Main.pref.getLong("cache." + ident + "." + "expire", EXPIRE_DAILY); |
| 86 | | if (this.expire < 0) { |
| 87 | | this.expire = CacheFiles.EXPIRE_NEVER; |
| 88 | | } |
| 89 | | this.maxsize = Main.pref.getLong("cache." + ident + "." + "maxsize", 50); |
| 90 | | if (this.maxsize < 0) { |
| 91 | | this.maxsize = -1; |
| 92 | | } |
| 93 | | } |
| 94 | | |
| 95 | | /** |
| 96 | | * Loads the data for the given ident as an byte array. Returns null if data not available. |
| 97 | | * @param ident cache identifier |
| 98 | | * @return stored data |
| 99 | | */ |
| 100 | | public byte[] getData(String ident) { |
| 101 | | if (!enabled) return null; |
| 102 | | try { |
| 103 | | File data = getPath(ident); |
| 104 | | if (!data.exists()) |
| 105 | | return null; |
| 106 | | |
| 107 | | if (isExpired(data)) { |
| 108 | | data.delete(); |
| 109 | | return null; |
| 110 | | } |
| 111 | | |
| 112 | | // Update last mod time so we don't expire recently used data |
| 113 | | if (updateModTime) { |
| 114 | | data.setLastModified(System.currentTimeMillis()); |
| 115 | | } |
| 116 | | |
| 117 | | byte[] bytes = new byte[(int) data.length()]; |
| 118 | | try (RandomAccessFile raf = new RandomAccessFile(data, "r")) { |
| 119 | | raf.readFully(bytes); |
| 120 | | } |
| 121 | | return bytes; |
| 122 | | } catch (Exception e) { |
| 123 | | Main.warn(e); |
| 124 | | } |
| 125 | | return null; |
| 126 | | } |
| 127 | | |
| 128 | | /** |
| 129 | | * Writes an byte-array to disk |
| 130 | | * @param ident cache identifier |
| 131 | | * @param data data to store |
| 132 | | */ |
| 133 | | public void saveData(String ident, byte[] data) { |
| 134 | | if (!enabled) return; |
| 135 | | try { |
| 136 | | File f = getPath(ident); |
| 137 | | if (f.exists()) { |
| 138 | | f.delete(); |
| 139 | | } |
| 140 | | // rws also updates the file meta-data, i.e. last mod time |
| 141 | | try (RandomAccessFile raf = new RandomAccessFile(f, "rws")) { |
| 142 | | raf.write(data); |
| 143 | | } |
| 144 | | } catch (Exception e) { |
| 145 | | Main.warn(e); |
| 146 | | } |
| 147 | | |
| 148 | | writes++; |
| 149 | | checkCleanUp(); |
| 150 | | } |
| 151 | | |
| 152 | | /** |
| 153 | | * Loads the data for the given ident as an image. If no image is found, null is returned |
| 154 | | * @param ident cache identifier |
| 155 | | * @return BufferedImage or null |
| 156 | | */ |
| 157 | | public BufferedImage getImg(String ident) { |
| 158 | | if (!enabled) return null; |
| 159 | | try { |
| 160 | | File img = getPath(ident, "png"); |
| 161 | | if (!img.exists()) |
| 162 | | return null; |
| 163 | | |
| 164 | | if (isExpired(img)) { |
| 165 | | img.delete(); |
| 166 | | return null; |
| 167 | | } |
| 168 | | // Update last mod time so we don't expire recently used images |
| 169 | | if (updateModTime) { |
| 170 | | img.setLastModified(System.currentTimeMillis()); |
| 171 | | } |
| 172 | | return ImageProvider.read(img, false, false); |
| 173 | | } catch (Exception e) { |
| 174 | | Main.warn(e); |
| 175 | | } |
| 176 | | return null; |
| 177 | | } |
| 178 | | |
| 179 | | /** |
| 180 | | * Saves a given image and ident to the cache |
| 181 | | * @param ident cache identifier |
| 182 | | * @param image imaga data for storage |
| 183 | | */ |
| 184 | | public void saveImg(String ident, BufferedImage image) { |
| 185 | | if (!enabled) return; |
| 186 | | try { |
| 187 | | ImageIO.write(image, "png", getPath(ident, "png")); |
| 188 | | } catch (Exception e) { |
| 189 | | Main.warn(e); |
| 190 | | } |
| 191 | | |
| 192 | | writes++; |
| 193 | | checkCleanUp(); |
| 194 | | } |
| 195 | | |
| 196 | | /** |
| 197 | | * Sets the amount of time data is stored before it gets expired |
| 198 | | * @param amount of time in seconds |
| 199 | | * @param force will also write it to the preferences |
| 200 | | */ |
| 201 | | public void setExpire(int amount, boolean force) { |
| 202 | | String key = "cache." + ident + "." + "expire"; |
| 203 | | if (!Main.pref.get(key).isEmpty() && !force) |
| 204 | | return; |
| 205 | | |
| 206 | | this.expire = amount > 0 ? amount : EXPIRE_NEVER; |
| 207 | | Main.pref.putLong(key, this.expire); |
| 208 | | } |
| 209 | | |
| 210 | | /** |
| 211 | | * Sets the amount of data stored in the cache |
| 212 | | * @param amount in Megabytes |
| 213 | | * @param force will also write it to the preferences |
| 214 | | */ |
| 215 | | public void setMaxSize(int amount, boolean force) { |
| 216 | | String key = "cache." + ident + "." + "maxsize"; |
| 217 | | if (!Main.pref.get(key).isEmpty() && !force) |
| 218 | | return; |
| 219 | | |
| 220 | | this.maxsize = amount > 0 ? amount : -1; |
| 221 | | Main.pref.putLong(key, this.maxsize); |
| 222 | | } |
| 223 | | |
| 224 | | /** |
| 225 | | * Call this with <code>true</code> to update the last modification time when a file it is read. |
| 226 | | * Call this with <code>false</code> to not update the last modification time when a file is read. |
| 227 | | * @param to update state |
| 228 | | */ |
| 229 | | public void setUpdateModTime(boolean to) { |
| 230 | | updateModTime = to; |
| 231 | | } |
| 232 | | |
| 233 | | /** |
| 234 | | * Checks if a clean up is needed and will do so if necessary |
| 235 | | */ |
| 236 | | public void checkCleanUp() { |
| 237 | | if (this.writes > CLEANUP_INTERVAL) { |
| 238 | | cleanUp(); |
| 239 | | } |
| 240 | | } |
| 241 | | |
| 242 | | /** |
| 243 | | * Performs a default clean up with the set values (deletes oldest files first) |
| 244 | | */ |
| 245 | | public void cleanUp() { |
| 246 | | if (!this.enabled || maxsize == -1) return; |
| 247 | | |
| 248 | | SortedMap<Long, File> modtime = new TreeMap<>(); |
| 249 | | long dirsize = 0; |
| 250 | | |
| 251 | | File[] files = dir.listFiles(); |
| 252 | | if (files != null) { |
| 253 | | for (File f : files) { |
| 254 | | if (isExpired(f)) { |
| 255 | | f.delete(); |
| 256 | | } else { |
| 257 | | dirsize += f.length(); |
| 258 | | modtime.put(f.lastModified(), f); |
| 259 | | } |
| 260 | | } |
| 261 | | } |
| 262 | | |
| 263 | | if (dirsize < maxsize*1000*1000) return; |
| 264 | | |
| 265 | | Set<Long> keySet = modtime.keySet(); |
| 266 | | Iterator<Long> it = keySet.iterator(); |
| 267 | | int i = 0; |
| 268 | | while (it.hasNext()) { |
| 269 | | i++; |
| 270 | | modtime.get(it.next()).delete(); |
| 271 | | |
| 272 | | // Delete a couple of files, then check again |
| 273 | | if (i % CLEANUP_TRESHOLD == 0 && getDirSize() < maxsize) |
| 274 | | return; |
| 275 | | } |
| 276 | | writes = 0; |
| 277 | | } |
| 278 | | |
| 279 | | public static final int CLEAN_ALL = 0; |
| 280 | | public static final int CLEAN_SMALL_FILES = 1; |
| 281 | | public static final int CLEAN_BY_DATE = 2; |
| 282 | | |
| 283 | | /** |
| 284 | | * Performs a non-default, specified clean up |
| 285 | | * @param type any of the CLEAN_XX constants. |
| 286 | | * @param size for CLEAN_SMALL_FILES: deletes all files smaller than (size) bytes |
| 287 | | */ |
| 288 | | public void customCleanUp(int type, int size) { |
| 289 | | File[] files; |
| 290 | | switch(type) { |
| 291 | | case CLEAN_ALL: |
| 292 | | files = dir.listFiles(); |
| 293 | | if (files != null) { |
| 294 | | for (File f : files) { |
| 295 | | f.delete(); |
| 296 | | } |
| 297 | | } |
| 298 | | break; |
| 299 | | case CLEAN_SMALL_FILES: |
| 300 | | files = dir.listFiles(); |
| 301 | | if (files != null) { |
| 302 | | for (File f: files) { |
| 303 | | if (f.length() < size) { |
| 304 | | f.delete(); |
| 305 | | } |
| 306 | | } |
| 307 | | } |
| 308 | | break; |
| 309 | | case CLEAN_BY_DATE: |
| 310 | | cleanUp(); |
| 311 | | break; |
| 312 | | } |
| 313 | | } |
| 314 | | |
| 315 | | /** |
| 316 | | * Calculates the size of the directory |
| 317 | | * @return long Size of directory in bytes |
| 318 | | */ |
| 319 | | private long getDirSize() { |
| 320 | | if (!enabled) return -1; |
| 321 | | long dirsize = 0; |
| 322 | | |
| 323 | | File[] files = dir.listFiles(); |
| 324 | | if (files != null) { |
| 325 | | for (File f : files) { |
| 326 | | dirsize += f.length(); |
| 327 | | } |
| 328 | | } |
| 329 | | return dirsize; |
| 330 | | } |
| 331 | | |
| 332 | | /** |
| 333 | | * Returns a short and unique file name for a given long identifier |
| 334 | | * @return String short filename |
| 335 | | */ |
| 336 | | private static String getUniqueFilename(String ident) { |
| 337 | | try { |
| 338 | | MessageDigest md = MessageDigest.getInstance("MD5"); |
| 339 | | BigInteger number = new BigInteger(1, md.digest(ident.getBytes(StandardCharsets.UTF_8))); |
| 340 | | return number.toString(16); |
| 341 | | } catch (Exception e) { |
| 342 | | // Fall back. Remove unsuitable characters and some random ones to shrink down path length. |
| 343 | | // Limit it to 70 characters, that leaves about 190 for the path on Windows/NTFS |
| 344 | | ident = ident.replaceAll("[^a-zA-Z0-9]", ""); |
| 345 | | ident = ident.replaceAll("[acegikmoqsuwy]", ""); |
| 346 | | return ident.substring(ident.length() - 70); |
| 347 | | } |
| 348 | | } |
| 349 | | |
| 350 | | /** |
| 351 | | * Gets file path for ident with customizable file-ending |
| 352 | | * @param ident cache identifier |
| 353 | | * @param ending file extension |
| 354 | | * @return file structure |
| 355 | | */ |
| 356 | | private File getPath(String ident, String ending) { |
| 357 | | return new File(dir, getUniqueFilename(ident) + "." + ending); |
| 358 | | } |
| 359 | | |
| 360 | | /** |
| 361 | | * Gets file path for ident |
| 362 | | * @param ident cache identifier |
| 363 | | * @return file structure |
| 364 | | */ |
| 365 | | private File getPath(String ident) { |
| 366 | | return new File(dir, getUniqueFilename(ident)); |
| 367 | | } |
| 368 | | |
| 369 | | /** |
| 370 | | * Checks whether a given file is expired |
| 371 | | * @param file file description structure |
| 372 | | * @return expired state |
| 373 | | */ |
| 374 | | private boolean isExpired(File file) { |
| 375 | | if (CacheFiles.EXPIRE_NEVER == this.expire) |
| 376 | | return false; |
| 377 | | return file.lastModified() < (System.currentTimeMillis() - expire*1000); |
| 378 | | } |
| 379 | | } |