diff --git a/README.md b/README.md index f7d6078..f2bd7e6 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ repositories { jcenter() } dependencies { - compile 'com.danikula:videocache:2.2.0' + compile 'com.danikula:videocache:2.3.0' } ``` diff --git a/library/build.gradle b/library/build.gradle index 3bdd043..4e58e7d 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -26,7 +26,7 @@ publish { userOrg = 'alexeydanilov' groupId = 'com.danikula' artifactId = 'videocache' - publishVersion = '2.2.0' + publishVersion = '2.3.0' description = 'Cache support for android VideoView' website = 'https://github.com/danikula/AndroidVideoCache' } diff --git a/library/src/main/java/com/danikula/videocache/Config.java b/library/src/main/java/com/danikula/videocache/Config.java new file mode 100644 index 0000000..bcf4e77 --- /dev/null +++ b/library/src/main/java/com/danikula/videocache/Config.java @@ -0,0 +1,30 @@ +package com.danikula.videocache; + +import com.danikula.videocache.file.DiskUsage; +import com.danikula.videocache.file.FileNameGenerator; + +import java.io.File; + +/** + * Configuration for proxy cache. + * + * @author Alexey Danilov (danikula@gmail.com). + */ +class Config { + + public final File cacheRoot; + public final FileNameGenerator fileNameGenerator; + public final DiskUsage diskUsage; + + Config(File cacheRoot, FileNameGenerator fileNameGenerator, DiskUsage diskUsage) { + this.cacheRoot = cacheRoot; + this.fileNameGenerator = fileNameGenerator; + this.diskUsage = diskUsage; + } + + File generateCacheFile(String url) { + String name = fileNameGenerator.generate(url); + return new File(cacheRoot, name); + } + +} diff --git a/library/src/main/java/com/danikula/videocache/HttpProxyCache.java b/library/src/main/java/com/danikula/videocache/HttpProxyCache.java index 61681bd..221815a 100644 --- a/library/src/main/java/com/danikula/videocache/HttpProxyCache.java +++ b/library/src/main/java/com/danikula/videocache/HttpProxyCache.java @@ -2,6 +2,8 @@ package com.danikula.videocache; import android.text.TextUtils; +import com.danikula.videocache.file.FileCache; + import java.io.BufferedOutputStream; import java.io.IOException; import java.io.OutputStream; diff --git a/library/src/main/java/com/danikula/videocache/HttpProxyCacheServer.java b/library/src/main/java/com/danikula/videocache/HttpProxyCacheServer.java index 53f89d1..fc11c55 100644 --- a/library/src/main/java/com/danikula/videocache/HttpProxyCacheServer.java +++ b/library/src/main/java/com/danikula/videocache/HttpProxyCacheServer.java @@ -1,8 +1,16 @@ package com.danikula.videocache; +import android.content.Context; import android.os.SystemClock; import android.util.Log; +import com.danikula.videocache.file.DiskUsage; +import com.danikula.videocache.file.FileNameGenerator; +import com.danikula.videocache.file.Md5FileNameGenerator; +import com.danikula.videocache.file.TotalCountLruDiskUsage; +import com.danikula.videocache.file.TotalSizeLruDiskUsage; + +import java.io.File; import java.io.IOException; import java.io.OutputStream; import java.net.InetAddress; @@ -56,11 +64,15 @@ public class HttpProxyCacheServer { private final ServerSocket serverSocket; private final int port; private final Thread waitConnectionThread; - private final FileNameGenerator fileNameGenerator; + private final Config config; private boolean pinged; - public HttpProxyCacheServer(FileNameGenerator fileNameGenerator) { - this.fileNameGenerator = checkNotNull(fileNameGenerator); + public HttpProxyCacheServer(Context context) { + this(new Builder(context).buildConfig()); + } + + private HttpProxyCacheServer(Config config) { + this.config = checkNotNull(config); try { InetAddress inetAddress = InetAddress.getByName(PROXY_HOST); this.serverSocket = new ServerSocket(0, 8, inetAddress); @@ -231,7 +243,7 @@ public class HttpProxyCacheServer { synchronized (clientsLock) { HttpProxyCacheServerClients clients = clientsMap.get(url); if (clients == null) { - clients = new HttpProxyCacheServerClients(url, fileNameGenerator); + clients = new HttpProxyCacheServerClients(url, config); clientsMap.put(url, clients); } return clients; @@ -328,4 +340,94 @@ public class HttpProxyCacheServer { return pingServer(); } } + + /** + * Builder for {@link HttpProxyCacheServer}. + */ + public static final class Builder { + + private static final long DEFAULT_MAX_SIZE = 512 * 104 * 1024; + + private File cacheRoot; + private FileNameGenerator fileNameGenerator; + private DiskUsage diskUsage; + + public Builder(Context context) { + this.cacheRoot = StorageUtils.getIndividualCacheDirectory(context); + this.diskUsage = new TotalSizeLruDiskUsage(DEFAULT_MAX_SIZE); + this.fileNameGenerator = new Md5FileNameGenerator(); + } + + /** + * Overrides default cache folder to be used for caching files. + *

+ * By default AndroidVideoCache uses + * '/Android/data/[app_package_name]/cache/video-cache/' if card is mounted and app has appropriate permission + * or 'video-cache' subdirectory in default application's cache directory otherwise. + *

+ * Note directory must be used only for AndroidVideoCache files. + * + * @param file a cache directory, can't be null. + * @return a builder. + */ + public Builder cacheDirectory(File file) { + this.cacheRoot = checkNotNull(file); + return this; + } + + /** + * Overrides default cache file name generator {@link Md5FileNameGenerator} . + * + * @param fileNameGenerator a new file name generator. + * @return a builder. + */ + public Builder fileNameGenerator(FileNameGenerator fileNameGenerator) { + this.fileNameGenerator = checkNotNull(fileNameGenerator); + return this; + } + + /** + * Sets max cache size in bytes. + * All files that exceeds limit will be deleted using LRU strategy. + * Default value is 512 Mb. + *

+ * Note this method overrides result of calling {@link #maxCacheFilesCount(int)} + * + * @param maxSize max cache size in bytes. + * @return a builder. + */ + public Builder maxCacheSize(long maxSize) { + this.diskUsage = new TotalSizeLruDiskUsage(maxSize); + return this; + } + + /** + * Sets max cache files count. + * All files that exceeds limit will be deleted using LRU strategy. + *

+ * Note this method overrides result of calling {@link #maxCacheSize(long)} + * + * @param count max cache files count. + * @return a builder. + */ + public Builder maxCacheFilesCount(int count) { + this.diskUsage = new TotalCountLruDiskUsage(count); + return this; + } + + /** + * Builds new instance of {@link HttpProxyCacheServer}. + * + * @return proxy cache. Only single instance should be used across whole app. + */ + public HttpProxyCacheServer build() { + Config config = buildConfig(); + return new HttpProxyCacheServer(config); + } + + private Config buildConfig() { + return new Config(cacheRoot, fileNameGenerator, diskUsage); + } + + } } diff --git a/library/src/main/java/com/danikula/videocache/HttpProxyCacheServerClients.java b/library/src/main/java/com/danikula/videocache/HttpProxyCacheServerClients.java index f46147d..8296d2a 100644 --- a/library/src/main/java/com/danikula/videocache/HttpProxyCacheServerClients.java +++ b/library/src/main/java/com/danikula/videocache/HttpProxyCacheServerClients.java @@ -4,6 +4,8 @@ import android.os.Handler; import android.os.Looper; import android.os.Message; +import com.danikula.videocache.file.FileCache; + import java.io.File; import java.io.IOException; import java.net.Socket; @@ -24,12 +26,12 @@ final class HttpProxyCacheServerClients { private final String url; private volatile HttpProxyCache proxyCache; private final List listeners = new CopyOnWriteArrayList<>(); - private final FileNameGenerator fileNameGenerator; private final CacheListener uiCacheListener; + private final Config config; - public HttpProxyCacheServerClients(String url, FileNameGenerator fileNameGenerator) { + public HttpProxyCacheServerClients(String url, Config config) { this.url = checkNotNull(url); - this.fileNameGenerator = checkNotNull(fileNameGenerator); + this.config = checkNotNull(config); this.uiCacheListener = new UiListenerHandler(url, listeners); } @@ -78,7 +80,7 @@ final class HttpProxyCacheServerClients { private HttpProxyCache newHttpProxyCache() throws ProxyCacheException { HttpUrlSource source = new HttpUrlSource(url); - FileCache cache = new FileCache(fileNameGenerator.generate(url)); + FileCache cache = new FileCache(config.generateCacheFile(url), config.diskUsage); HttpProxyCache httpProxyCache = new HttpProxyCache(source, cache); httpProxyCache.registerCacheListener(uiCacheListener); return httpProxyCache; diff --git a/library/src/main/java/com/danikula/videocache/ProxyCacheUtils.java b/library/src/main/java/com/danikula/videocache/ProxyCacheUtils.java index ea567c6..226fa79 100644 --- a/library/src/main/java/com/danikula/videocache/ProxyCacheUtils.java +++ b/library/src/main/java/com/danikula/videocache/ProxyCacheUtils.java @@ -5,7 +5,6 @@ import android.util.Log; import android.webkit.MimeTypeMap; import java.io.Closeable; -import java.io.File; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; @@ -22,7 +21,7 @@ import static com.danikula.videocache.Preconditions.checkNotNull; * * @author Alexey Danilov (danikula@gmail.com). */ -class ProxyCacheUtils { +public class ProxyCacheUtils { static final String LOG_TAG = "ProxyCache"; static final int DEFAULT_BUFFER_SIZE = 8 * 1024; @@ -50,19 +49,6 @@ class ProxyCacheUtils { return preview; } - static void createDirectory(File directory) throws IOException { - checkNotNull(directory, "File must be not null!"); - if (directory.exists()) { - checkArgument(directory.isDirectory(), "File is not directory!"); - } else { - boolean isCreated = directory.mkdirs(); - if (!isCreated) { - String error = String.format("Directory %s can't be created", directory.getAbsolutePath()); - throw new IOException(error); - } - } - } - static String encode(String url) { try { return URLEncoder.encode(url, "utf-8"); @@ -89,7 +75,7 @@ class ProxyCacheUtils { } } - static String computeMD5(String string) { + public static String computeMD5(String string) { try { MessageDigest messageDigest = MessageDigest.getInstance("MD5"); byte[] digestBytes = messageDigest.digest(string.getBytes()); @@ -99,12 +85,11 @@ class ProxyCacheUtils { } } - static String bytesToHexString(byte[] bytes) { + private static String bytesToHexString(byte[] bytes) { StringBuffer sb = new StringBuffer(); for (byte b : bytes) { sb.append(String.format("%02x", b)); } return sb.toString(); } - } diff --git a/library/src/main/java/com/danikula/videocache/StorageUtils.java b/library/src/main/java/com/danikula/videocache/StorageUtils.java new file mode 100644 index 0000000..3772f29 --- /dev/null +++ b/library/src/main/java/com/danikula/videocache/StorageUtils.java @@ -0,0 +1,81 @@ +package com.danikula.videocache; + +import android.content.Context; +import android.os.Environment; +import android.util.Log; + +import java.io.File; + +import static android.os.Environment.MEDIA_MOUNTED; +import static com.danikula.videocache.ProxyCacheUtils.LOG_TAG; + +/** + * Provides application storage paths + *

+ * See https://github.com/nostra13/Android-Universal-Image-Loader + * + * @author Sergey Tarasevich (nostra13[at]gmail[dot]com) + * @since 1.0.0 + */ +final class StorageUtils { + + private static final String INDIVIDUAL_DIR_NAME = "video-cache"; + + /** + * Returns individual application cache directory (for only video caching from Proxy). Cache directory will be + * created on SD card ("/Android/data/[app_package_name]/cache/video-cache") if card is mounted . + * Else - Android defines cache directory on device's file system. + * + * @param context Application context + * @return Cache {@link File directory} + */ + public static File getIndividualCacheDirectory(Context context) { + File cacheDir = getCacheDirectory(context, true); + return new File(cacheDir, INDIVIDUAL_DIR_NAME); + } + + /** + * Returns application cache directory. Cache directory will be created on SD card + * ("/Android/data/[app_package_name]/cache") (if card is mounted and app has appropriate permission) or + * on device's file system depending incoming parameters. + * + * @param context Application context + * @param preferExternal Whether prefer external location for cache + * @return Cache {@link File directory}.
+ * NOTE: Can be null in some unpredictable cases (if SD card is unmounted and + * {@link android.content.Context#getCacheDir() Context.getCacheDir()} returns null). + */ + private static File getCacheDirectory(Context context, boolean preferExternal) { + File appCacheDir = null; + String externalStorageState; + try { + externalStorageState = Environment.getExternalStorageState(); + } catch (NullPointerException e) { // (sh)it happens + externalStorageState = ""; + } + if (preferExternal && MEDIA_MOUNTED.equals(externalStorageState)) { + appCacheDir = getExternalCacheDir(context); + } + if (appCacheDir == null) { + appCacheDir = context.getCacheDir(); + } + if (appCacheDir == null) { + String cacheDirPath = "/data/data/" + context.getPackageName() + "/cache/"; + Log.w(LOG_TAG, "Can't define system cache directory! '" + cacheDirPath + "%s' will be used."); + appCacheDir = new File(cacheDirPath); + } + return appCacheDir; + } + + private static File getExternalCacheDir(Context context) { + File dataDir = new File(new File(Environment.getExternalStorageDirectory(), "Android"), "data"); + File appCacheDir = new File(new File(dataDir, context.getPackageName()), "cache"); + if (!appCacheDir.exists()) { + if (!appCacheDir.mkdirs()) { + Log.w(LOG_TAG, "Unable to create external cache directory"); + return null; + } + } + return appCacheDir; + } +} diff --git a/library/src/main/java/com/danikula/videocache/file/DiskUsage.java b/library/src/main/java/com/danikula/videocache/file/DiskUsage.java new file mode 100644 index 0000000..1a43694 --- /dev/null +++ b/library/src/main/java/com/danikula/videocache/file/DiskUsage.java @@ -0,0 +1,15 @@ +package com.danikula.videocache.file; + +import java.io.File; +import java.io.IOException; + +/** + * Declares how {@link FileCache} will use disc space. + * + * @author Alexey Danilov (danikula@gmail.com). + */ +public interface DiskUsage { + + void touch(File file) throws IOException; + +} diff --git a/library/src/main/java/com/danikula/videocache/FileCache.java b/library/src/main/java/com/danikula/videocache/file/FileCache.java similarity index 86% rename from library/src/main/java/com/danikula/videocache/FileCache.java rename to library/src/main/java/com/danikula/videocache/file/FileCache.java index a99034c..dc4a68e 100644 --- a/library/src/main/java/com/danikula/videocache/FileCache.java +++ b/library/src/main/java/com/danikula/videocache/file/FileCache.java @@ -1,11 +1,12 @@ -package com.danikula.videocache; +package com.danikula.videocache.file; + +import com.danikula.videocache.Cache; +import com.danikula.videocache.ProxyCacheException; import java.io.File; import java.io.IOException; import java.io.RandomAccessFile; -import static com.danikula.videocache.Preconditions.checkNotNull; - /** * {@link Cache} that uses file for storing data. * @@ -15,13 +16,22 @@ public class FileCache implements Cache { private static final String TEMP_POSTFIX = ".download"; + private final DiskUsage diskUsage; public File file; private RandomAccessFile dataFile; public FileCache(File file) throws ProxyCacheException { + this(file, new UnlimitedDiskUsage()); + } + + public FileCache(File file, DiskUsage diskUsage) throws ProxyCacheException { try { - checkNotNull(file); - ProxyCacheUtils.createDirectory(file.getParentFile()); + if (diskUsage == null) { + throw new NullPointerException(); + } + this.diskUsage = diskUsage; + File directory = file.getParentFile(); + Files.makeDir(directory); boolean completed = file.exists(); this.file = completed ? file : new File(file.getParentFile(), file.getName() + TEMP_POSTFIX); this.dataFile = new RandomAccessFile(this.file, completed ? "r" : "rw"); @@ -68,6 +78,7 @@ public class FileCache implements Cache { public synchronized void close() throws ProxyCacheException { try { dataFile.close(); + diskUsage.touch(file); } catch (IOException e) { throw new ProxyCacheException("Error closing file " + file, e); } diff --git a/library/src/main/java/com/danikula/videocache/FileNameGenerator.java b/library/src/main/java/com/danikula/videocache/file/FileNameGenerator.java similarity index 63% rename from library/src/main/java/com/danikula/videocache/FileNameGenerator.java rename to library/src/main/java/com/danikula/videocache/file/FileNameGenerator.java index afc7c6d..cd8b344 100644 --- a/library/src/main/java/com/danikula/videocache/FileNameGenerator.java +++ b/library/src/main/java/com/danikula/videocache/file/FileNameGenerator.java @@ -1,6 +1,4 @@ -package com.danikula.videocache; - -import java.io.File; +package com.danikula.videocache.file; /** * Generator for files to be used for caching. @@ -9,6 +7,6 @@ import java.io.File; */ public interface FileNameGenerator { - File generate(String url); + String generate(String url); } diff --git a/library/src/main/java/com/danikula/videocache/file/Files.java b/library/src/main/java/com/danikula/videocache/file/Files.java new file mode 100644 index 0000000..cf8b665 --- /dev/null +++ b/library/src/main/java/com/danikula/videocache/file/Files.java @@ -0,0 +1,88 @@ +package com.danikula.videocache.file; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedList; +import java.util.List; + +/** + * Utils for work with files. + * + * @author Alexey Danilov (danikula@gmail.com). + */ +class Files { + + static void makeDir(File directory) throws IOException { + if (directory.exists()) { + if (!directory.isDirectory()) { + throw new IOException("File " + directory + " is not directory!"); + } + } else { + boolean isCreated = directory.mkdirs(); + if (!isCreated) { + throw new IOException(String.format("Directory %s can't be created", directory.getAbsolutePath())); + } + } + } + + static List getLruListFiles(File directory) { + List result = new LinkedList<>(); + File[] files = directory.listFiles(); + if (files != null) { + result = Arrays.asList(files); + Collections.sort(result, new LastModifiedComparator()); + } + return result; + } + + static void setLastModifiedNow(File file) throws IOException { + if (file.exists()) { + long now = System.currentTimeMillis(); + boolean modified = file.setLastModified(now); // on some devices (e.g. Nexus 5) doesn't work + if (!modified) { + modify(file); + if (file.lastModified() < now) { + throw new IOException("Error set last modified date to " + file); + } + } + } + } + + static void modify(File file) throws IOException { + long size = file.length(); + if (size == 0) { + recreateZeroSizeFile(file); + return; + } + + RandomAccessFile accessFile = new RandomAccessFile(file, "rwd"); + accessFile.seek(size - 1); + byte lastByte = accessFile.readByte(); + accessFile.seek(size - 1); + accessFile.write(lastByte); + accessFile.close(); + } + + private static void recreateZeroSizeFile(File file) throws IOException { + if (!file.delete() || !file.createNewFile()) { + throw new IOException("Error recreate zero-size file " + file); + } + } + + private static final class LastModifiedComparator implements Comparator { + + @Override + public int compare(File lhs, File rhs) { + return compareLong(lhs.lastModified(), rhs.lastModified()); + } + + private int compareLong(long first, long second) { + return (first < second) ? -1 : ((first == second) ? 0 : 1); + } + } + +} diff --git a/library/src/main/java/com/danikula/videocache/file/LruDiskUsage.java b/library/src/main/java/com/danikula/videocache/file/LruDiskUsage.java new file mode 100644 index 0000000..f8925f2 --- /dev/null +++ b/library/src/main/java/com/danikula/videocache/file/LruDiskUsage.java @@ -0,0 +1,76 @@ +package com.danikula.videocache.file; + +import android.util.Log; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * {@link DiskUsage} that uses LRU (Least Recently Used) strategy to trim cache. + * + * @author Alexey Danilov (danikula@gmail.com). + */ +abstract class LruDiskUsage implements DiskUsage { + + private static final String LOG_TAG = "ProxyCache"; + private final ExecutorService workerThread = Executors.newSingleThreadExecutor(); + + @Override + public void touch(File file) throws IOException { + workerThread.submit(new TouchCallable(file)); + } + + private void touchInBackground(File file) throws IOException { + Files.setLastModifiedNow(file); + List files = Files.getLruListFiles(file.getParentFile()); + trim(files); + } + + protected abstract boolean accept(File file, long totalSize, int totalCount); + + private void trim(List files) { + long totalSize = countTotalSize(files); + int totalCount = files.size(); + for (File file : files) { + boolean accepted = accept(file, totalSize, totalCount); + if (!accepted) { + long fileSize = file.length(); + boolean deleted = file.delete(); + if (deleted) { + totalCount--; + totalSize -= fileSize; + Log.i(LOG_TAG, "Cache file " + file + " is deleted because it exceeds cache limit"); + } else { + Log.e(LOG_TAG, "Error deleting file " + file + " for trimming cache"); + } + } + } + } + + private long countTotalSize(List files) { + long totalSize = 0; + for (File file : files) { + totalSize += file.length(); + } + return totalSize; + } + + private class TouchCallable implements Callable { + + private final File file; + + public TouchCallable(File file) { + this.file = file; + } + + @Override + public Void call() throws Exception { + touchInBackground(file); + return null; + } + } +} diff --git a/library/src/main/java/com/danikula/videocache/Md5FileNameGenerator.java b/library/src/main/java/com/danikula/videocache/file/Md5FileNameGenerator.java similarity index 62% rename from library/src/main/java/com/danikula/videocache/Md5FileNameGenerator.java rename to library/src/main/java/com/danikula/videocache/file/Md5FileNameGenerator.java index 6d460b8..27d72ee 100644 --- a/library/src/main/java/com/danikula/videocache/Md5FileNameGenerator.java +++ b/library/src/main/java/com/danikula/videocache/file/Md5FileNameGenerator.java @@ -1,10 +1,8 @@ -package com.danikula.videocache; +package com.danikula.videocache.file; import android.text.TextUtils; -import java.io.File; - -import static com.danikula.videocache.Preconditions.checkNotNull; +import com.danikula.videocache.ProxyCacheUtils; /** * Implementation of {@link FileNameGenerator} that uses MD5 of url as file name @@ -14,19 +12,12 @@ import static com.danikula.videocache.Preconditions.checkNotNull; public class Md5FileNameGenerator implements FileNameGenerator { private static final int MAX_EXTENSION_LENGTH = 4; - private final File cacheDirectory; - - public Md5FileNameGenerator(File cacheDirectory) { - this.cacheDirectory = checkNotNull(cacheDirectory); - } @Override - public File generate(String url) { - checkNotNull(url); + public String generate(String url) { String extension = getExtension(url); String name = ProxyCacheUtils.computeMD5(url); - name = TextUtils.isEmpty(extension) ? name : name + "." + extension; - return new File(cacheDirectory, name); + return TextUtils.isEmpty(extension) ? name : name + "." + extension; } private String getExtension(String url) { diff --git a/library/src/main/java/com/danikula/videocache/file/TotalCountLruDiskUsage.java b/library/src/main/java/com/danikula/videocache/file/TotalCountLruDiskUsage.java new file mode 100644 index 0000000..784b658 --- /dev/null +++ b/library/src/main/java/com/danikula/videocache/file/TotalCountLruDiskUsage.java @@ -0,0 +1,25 @@ +package com.danikula.videocache.file; + +import java.io.File; + +/** + * {@link DiskUsage} that uses LRU (Least Recently Used) strategy and trims cache size to max files count if needed. + * + * @author Alexey Danilov (danikula@gmail.com). + */ +public class TotalCountLruDiskUsage extends LruDiskUsage { + + private final int maxCount; + + public TotalCountLruDiskUsage(int maxCount) { + if (maxCount <= 0) { + throw new IllegalArgumentException("Max count must be positive number!"); + } + this.maxCount = maxCount; + } + + @Override + protected boolean accept(File file, long totalSize, int totalCount) { + return totalCount <= maxCount; + } +} diff --git a/library/src/main/java/com/danikula/videocache/file/TotalSizeLruDiskUsage.java b/library/src/main/java/com/danikula/videocache/file/TotalSizeLruDiskUsage.java new file mode 100644 index 0000000..7dd8e27 --- /dev/null +++ b/library/src/main/java/com/danikula/videocache/file/TotalSizeLruDiskUsage.java @@ -0,0 +1,25 @@ +package com.danikula.videocache.file; + +import java.io.File; + +/** + * {@link DiskUsage} that uses LRU (Least Recently Used) strategy and trims cache size to max size if needed. + * + * @author Alexey Danilov (danikula@gmail.com). + */ +public class TotalSizeLruDiskUsage extends LruDiskUsage { + + private final long maxSize; + + public TotalSizeLruDiskUsage(long maxSize) { + if (maxSize <= 0) { + throw new IllegalArgumentException("Max size must be positive number!"); + } + this.maxSize = maxSize; + } + + @Override + protected boolean accept(File file, long totalSize, int totalCount) { + return totalSize <= maxSize; + } +} diff --git a/library/src/main/java/com/danikula/videocache/file/UnlimitedDiskUsage.java b/library/src/main/java/com/danikula/videocache/file/UnlimitedDiskUsage.java new file mode 100644 index 0000000..85ae66c --- /dev/null +++ b/library/src/main/java/com/danikula/videocache/file/UnlimitedDiskUsage.java @@ -0,0 +1,17 @@ +package com.danikula.videocache.file; + +import java.io.File; +import java.io.IOException; + +/** + * Unlimited version of {@link DiskUsage}. + * + * @author Alexey Danilov (danikula@gmail.com). + */ +public class UnlimitedDiskUsage implements DiskUsage { + + @Override + public void touch(File file) throws IOException { + // do nothing + } +} diff --git a/sample/build.gradle b/sample/build.gradle index c05b9c1..b525bec 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -35,10 +35,10 @@ apt { } dependencies { -// compile project(':library') + compile project(':library') compile 'com.android.support:support-v4:23.1.0' compile 'org.androidannotations:androidannotations-api:3.3.2' - compile 'com.danikula:videocache:2.2.0' +// compile 'com.danikula:videocache:2.2.0' compile 'com.viewpagerindicator:library:2.4.2-SNAPSHOT@aar' apt 'org.androidannotations:androidannotations:3.3.2' } diff --git a/sample/src/main/java/com/danikula/videocache/sample/App.java b/sample/src/main/java/com/danikula/videocache/sample/App.java index 9372f1c..6a4118c 100644 --- a/sample/src/main/java/com/danikula/videocache/sample/App.java +++ b/sample/src/main/java/com/danikula/videocache/sample/App.java @@ -3,9 +3,7 @@ package com.danikula.videocache.sample; import android.app.Application; import android.content.Context; -import com.danikula.videocache.FileNameGenerator; import com.danikula.videocache.HttpProxyCacheServer; -import com.danikula.videocache.Md5FileNameGenerator; /** * @author Alexey Danilov (danikula@gmail.com). @@ -20,7 +18,6 @@ public class App extends Application { } private HttpProxyCacheServer newProxy() { - FileNameGenerator nameGenerator = new Md5FileNameGenerator(getExternalCacheDir()); - return new HttpProxyCacheServer(nameGenerator); + return new HttpProxyCacheServer(this); } } diff --git a/test/src/test/java/com/danikula/videocache/FileNameGeneratorTest.java b/test/src/test/java/com/danikula/videocache/FileNameGeneratorTest.java index 0fdd88d..ea8a4a0 100644 --- a/test/src/test/java/com/danikula/videocache/FileNameGeneratorTest.java +++ b/test/src/test/java/com/danikula/videocache/FileNameGeneratorTest.java @@ -1,5 +1,7 @@ package com.danikula.videocache; +import com.danikula.videocache.file.FileNameGenerator; +import com.danikula.videocache.file.Md5FileNameGenerator; import com.danikula.videocache.test.BuildConfig; import org.junit.Test; @@ -62,21 +64,16 @@ public class FileNameGeneratorTest { assertThat(path).isEqualTo(expected); } - @Test(expected = NullPointerException.class) - public void testAssertNullRoot() throws Exception { - new Md5FileNameGenerator(null); - fail("Root folder should be not null"); - } - @Test(expected = NullPointerException.class) public void testAssertNullUrl() throws Exception { - FileNameGenerator nameGenerator = new Md5FileNameGenerator(new File("/")); + FileNameGenerator nameGenerator = new Md5FileNameGenerator(); nameGenerator.generate(null); fail("Url should be not null"); } private String generateMd5Name(String rootFolder, String url) { - FileNameGenerator nameGenerator = new Md5FileNameGenerator(new File(rootFolder)); - return nameGenerator.generate(url).getAbsolutePath(); + FileNameGenerator nameGenerator = new Md5FileNameGenerator(); + String name = nameGenerator.generate(url); + return new File(rootFolder, name).getAbsolutePath(); } } diff --git a/test/src/test/java/com/danikula/videocache/HttpProxyCacheServerTest.java b/test/src/test/java/com/danikula/videocache/HttpProxyCacheServerTest.java index 8aaa9da..f6da326 100644 --- a/test/src/test/java/com/danikula/videocache/HttpProxyCacheServerTest.java +++ b/test/src/test/java/com/danikula/videocache/HttpProxyCacheServerTest.java @@ -3,10 +3,13 @@ package com.danikula.videocache; import android.util.Pair; import com.danikula.android.garden.io.IoUtils; +import com.danikula.videocache.file.FileNameGenerator; +import com.danikula.videocache.file.Md5FileNameGenerator; import com.danikula.videocache.support.ProxyCacheTestUtils; import com.danikula.videocache.support.Response; import com.danikula.videocache.test.BuildConfig; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricGradleTestRunner; @@ -17,12 +20,18 @@ import java.io.File; import java.io.IOException; import java.util.Arrays; +import static com.danikula.android.garden.io.Files.cleanDirectory; +import static com.danikula.android.garden.io.Files.createDirectory; import static com.danikula.videocache.support.ProxyCacheTestUtils.ASSETS_DATA_BIG_NAME; import static com.danikula.videocache.support.ProxyCacheTestUtils.ASSETS_DATA_NAME; import static com.danikula.videocache.support.ProxyCacheTestUtils.HTTP_DATA_BIG_SIZE; import static com.danikula.videocache.support.ProxyCacheTestUtils.HTTP_DATA_BIG_URL; import static com.danikula.videocache.support.ProxyCacheTestUtils.HTTP_DATA_BIG_URL_ONE_REDIRECT; +import static com.danikula.videocache.support.ProxyCacheTestUtils.HTTP_DATA_SIZE; import static com.danikula.videocache.support.ProxyCacheTestUtils.HTTP_DATA_URL; +import static com.danikula.videocache.support.ProxyCacheTestUtils.HTTP_DATA_URL_3_REDIRECTS; +import static com.danikula.videocache.support.ProxyCacheTestUtils.HTTP_DATA_URL_6_REDIRECTS; +import static com.danikula.videocache.support.ProxyCacheTestUtils.HTTP_DATA_URL_ONE_REDIRECT; import static com.danikula.videocache.support.ProxyCacheTestUtils.getFileContent; import static com.danikula.videocache.support.ProxyCacheTestUtils.loadAssetFile; import static com.danikula.videocache.support.ProxyCacheTestUtils.readProxyResponse; @@ -35,6 +44,15 @@ import static org.fest.assertions.api.Assertions.assertThat; @Config(constants = BuildConfig.class, emulateSdk = BuildConfig.MIN_SDK_VERSION) public class HttpProxyCacheServerTest { + private File cacheFolder; + + @Before + public void setup() throws Exception { + cacheFolder = ProxyCacheTestUtils.newCacheFile(); + createDirectory(cacheFolder); + cleanDirectory(cacheFolder); + } + @Test public void testHttpProxyCache() throws Exception { Pair response = readProxyData(HTTP_DATA_URL); @@ -46,14 +64,14 @@ public class HttpProxyCacheServerTest { @Test public void testProxyContentWithPartialCache() throws Exception { - FileNameGenerator fileNameGenerator = new Md5FileNameGenerator(RuntimeEnvironment.application.getExternalCacheDir()); - File file = fileNameGenerator.generate(HTTP_DATA_URL); + File cacheDir = RuntimeEnvironment.application.getExternalCacheDir(); + File file = new File(cacheDir, new Md5FileNameGenerator().generate(HTTP_DATA_URL)); int partialCacheSize = 1000; byte[] partialData = ProxyCacheTestUtils.generate(partialCacheSize); File partialCacheFile = ProxyCacheTestUtils.getTempFile(file); IoUtils.saveToFile(partialData, partialCacheFile); - HttpProxyCacheServer proxy = new HttpProxyCacheServer(fileNameGenerator); + HttpProxyCacheServer proxy = newProxy(cacheDir); Response response = readProxyResponse(proxy, HTTP_DATA_URL); proxy.shutdown(); @@ -132,11 +150,70 @@ public class HttpProxyCacheServerTest { assertThat(getFileContent(response.first)).isEqualTo(loadAssetFile(ASSETS_DATA_BIG_NAME)); } + @Test + public void testMaxSizeCacheLimit() throws Exception { + HttpProxyCacheServer proxy = new HttpProxyCacheServer.Builder(RuntimeEnvironment.application) + .cacheDirectory(cacheFolder) + .maxCacheSize(HTTP_DATA_SIZE * 3 - 1) // for 2 files + .build(); + + // use different url (doesn't matter than same content) + readProxyResponse(proxy, HTTP_DATA_URL, 0); + Thread.sleep(1050); // wait for new last modified date (file rounds time to second) + + readProxyResponse(proxy, HTTP_DATA_URL_ONE_REDIRECT, 0); + Thread.sleep(1050); + + readProxyResponse(proxy, HTTP_DATA_URL_3_REDIRECTS, 0); + Thread.sleep(1050); + + assertThat(file(cacheFolder, HTTP_DATA_URL)).doesNotExist(); + assertThat(file(cacheFolder, HTTP_DATA_URL_ONE_REDIRECT)).exists(); + assertThat(file(cacheFolder, HTTP_DATA_URL_3_REDIRECTS)).exists(); + + readProxyResponse(proxy, HTTP_DATA_URL_ONE_REDIRECT, 0); // touch file + readProxyResponse(proxy, HTTP_DATA_URL_6_REDIRECTS, 0); + proxy.shutdown(); + + assertThat(file(cacheFolder, HTTP_DATA_URL_3_REDIRECTS)).doesNotExist(); + assertThat(file(cacheFolder, HTTP_DATA_URL_ONE_REDIRECT)).exists(); + assertThat(file(cacheFolder, HTTP_DATA_URL_6_REDIRECTS)).exists(); + } + + @Test + public void testMaxFileCacheLimit() throws Exception { + HttpProxyCacheServer proxy = new HttpProxyCacheServer.Builder(RuntimeEnvironment.application) + .cacheDirectory(cacheFolder) + .maxCacheFilesCount(2) + .build(); + + // use different url (doesn't matter than same content) + readProxyResponse(proxy, HTTP_DATA_URL, 0); + Thread.sleep(1050); // wait for new last modified date (file rounds time to second) + + readProxyResponse(proxy, HTTP_DATA_URL_ONE_REDIRECT, 0); + Thread.sleep(1050); + + readProxyResponse(proxy, HTTP_DATA_URL_3_REDIRECTS, 0); + Thread.sleep(1050); + + assertThat(file(cacheFolder, HTTP_DATA_URL)).doesNotExist(); + assertThat(file(cacheFolder, HTTP_DATA_URL_ONE_REDIRECT)).exists(); + assertThat(file(cacheFolder, HTTP_DATA_URL_3_REDIRECTS)).exists(); + + readProxyResponse(proxy, HTTP_DATA_URL_ONE_REDIRECT, 0); // touch file + readProxyResponse(proxy, HTTP_DATA_URL_6_REDIRECTS, 0); + proxy.shutdown(); + + assertThat(file(cacheFolder, HTTP_DATA_URL_3_REDIRECTS)).doesNotExist(); + assertThat(file(cacheFolder, HTTP_DATA_URL_ONE_REDIRECT)).exists(); + assertThat(file(cacheFolder, HTTP_DATA_URL_6_REDIRECTS)).exists(); + } + private Pair readProxyData(String url, int offset) throws IOException { File externalCacheDir = RuntimeEnvironment.application.getExternalCacheDir(); - FileNameGenerator fileNameGenerator = new Md5FileNameGenerator(externalCacheDir); - File file = fileNameGenerator.generate(url); - HttpProxyCacheServer proxy = new HttpProxyCacheServer(fileNameGenerator); + File file = file(externalCacheDir, url); + HttpProxyCacheServer proxy = newProxy(externalCacheDir); Response response = readProxyResponse(proxy, url, offset); proxy.shutdown(); @@ -144,7 +221,19 @@ public class HttpProxyCacheServerTest { return new Pair<>(file, response); } + private File file(File parent, String url) { + FileNameGenerator fileNameGenerator = new Md5FileNameGenerator(); + String name = fileNameGenerator.generate(url); + return new File(parent, name); + } + private Pair readProxyData(String url) throws IOException { return readProxyData(url, -1); } + + private HttpProxyCacheServer newProxy(File cacheDir) { + return new HttpProxyCacheServer.Builder(RuntimeEnvironment.application) + .cacheDirectory(cacheDir) + .build(); + } } diff --git a/test/src/test/java/com/danikula/videocache/HttpProxyCacheTest.java b/test/src/test/java/com/danikula/videocache/HttpProxyCacheTest.java index b1b2d7b..c558e17 100644 --- a/test/src/test/java/com/danikula/videocache/HttpProxyCacheTest.java +++ b/test/src/test/java/com/danikula/videocache/HttpProxyCacheTest.java @@ -1,5 +1,6 @@ package com.danikula.videocache; +import com.danikula.videocache.file.FileCache; import com.danikula.videocache.support.ProxyCacheTestUtils; import com.danikula.videocache.support.Response; import com.danikula.videocache.test.BuildConfig; diff --git a/test/src/test/java/com/danikula/videocache/ProxyCacheTest.java b/test/src/test/java/com/danikula/videocache/ProxyCacheTest.java index 5654f33..5fc64b3 100644 --- a/test/src/test/java/com/danikula/videocache/ProxyCacheTest.java +++ b/test/src/test/java/com/danikula/videocache/ProxyCacheTest.java @@ -1,6 +1,7 @@ package com.danikula.videocache; import com.danikula.android.garden.io.IoUtils; +import com.danikula.videocache.file.FileCache; import com.danikula.videocache.support.AngryHttpUrlSource; import com.danikula.videocache.support.PhlegmaticByteArraySource; import com.danikula.videocache.test.BuildConfig; diff --git a/test/src/test/java/com/danikula/videocache/file/DiskUsageTest.java b/test/src/test/java/com/danikula/videocache/file/DiskUsageTest.java new file mode 100644 index 0000000..152afe3 --- /dev/null +++ b/test/src/test/java/com/danikula/videocache/file/DiskUsageTest.java @@ -0,0 +1,122 @@ +package com.danikula.videocache.file; + +import com.danikula.videocache.support.ProxyCacheTestUtils; +import com.danikula.videocache.test.BuildConfig; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricGradleTestRunner; +import org.robolectric.annotation.Config; + +import java.io.File; +import java.io.IOException; + +import static com.danikula.android.garden.io.Files.cleanDirectory; +import static com.danikula.android.garden.io.Files.createDirectory; +import static org.fest.assertions.api.Assertions.assertThat; + +/** + * Tests for implementations of {@link DiskUsage}. + * + * @author Alexey Danilov (danikula@gmail.com). + */ +@RunWith(RobolectricGradleTestRunner.class) +@Config(constants = BuildConfig.class, emulateSdk = BuildConfig.MIN_SDK_VERSION) +public class DiskUsageTest { + + private File cacheFolder; + + @Before + public void setup() throws Exception { + cacheFolder = ProxyCacheTestUtils.newCacheFile(); + createDirectory(cacheFolder); + cleanDirectory(cacheFolder); + } + + @Test + public void testMaxSizeCacheLimit() throws Exception { + DiskUsage diskUsage = new TotalSizeLruDiskUsage(300); + long now = System.currentTimeMillis(); + createFile(file("b"), 101, now - 10000); + createFile(file("c"), 102, now - 8000); + createFile(file("a"), 104, now - 4000); // exceeds + + diskUsage.touch(file("c")); + waitForAsyncTrimming(); + + assertThat(file("b")).doesNotExist(); + assertThat(file("c")).exists(); + assertThat(file("a")).exists(); + + createFile(file("d"), 500, now); // exceeds all + diskUsage.touch(file("d")); + waitForAsyncTrimming(); + + assertThat(file("a")).doesNotExist(); + assertThat(file("c")).doesNotExist(); + assertThat(file("d")).doesNotExist(); + } + + @Test + public void testMaxFilesCount() throws Exception { + DiskUsage diskUsage = new TotalCountLruDiskUsage(2); + long now = System.currentTimeMillis(); + createFile(file("b"), 101, now - 10000); + createFile(file("c"), 102, now - 8000); + createFile(file("a"), 104, now - 4000); + + diskUsage.touch(file("c")); + waitForAsyncTrimming(); + + assertThat(file("b")).doesNotExist(); + assertThat(file("a")).exists(); + assertThat(file("c")).exists(); + + createFile(file("d"), 500, now); + diskUsage.touch(file("d")); + waitForAsyncTrimming(); + + assertThat(file("a")).doesNotExist(); + assertThat(file("c")).exists(); + assertThat(file("d")).exists(); + } + + @Test + public void testTouch() throws Exception { + DiskUsage diskUsage = new TotalCountLruDiskUsage(2); + long now = System.currentTimeMillis(); + createFile(file("b"), 101, now - 10000); + createFile(file("c"), 102, now - 8000); + createFile(file("a"), 104, now - 4000); + + diskUsage.touch(file("b")); + waitForAsyncTrimming(); + + assertThat(file("b")).exists(); + assertThat(file("a")).exists(); + assertThat(file("c")).doesNotExist(); + + Thread.sleep(1000); // last modified is rounded to seconds, so wait for sec + new TotalCountLruDiskUsage(1).touch(file("a")); + waitForAsyncTrimming(); + + assertThat(file("a")).exists(); + assertThat(file("b")).doesNotExist(); + } + + private void waitForAsyncTrimming() throws InterruptedException { + Thread.sleep(200); + } + + private File file(String name) { + return new File(cacheFolder, name); + } + + private void createFile(File file, int capacity, long lastModified) throws IOException { + byte[] data = ProxyCacheTestUtils.generate(capacity); + com.google.common.io.Files.write(data, file); + boolean modified = file.setLastModified(lastModified); + assertThat(modified).isTrue(); + } +} diff --git a/test/src/test/java/com/danikula/videocache/FileCacheTest.java b/test/src/test/java/com/danikula/videocache/file/FileCacheTest.java similarity index 97% rename from test/src/test/java/com/danikula/videocache/FileCacheTest.java rename to test/src/test/java/com/danikula/videocache/file/FileCacheTest.java index 2054bda..36d6fae 100644 --- a/test/src/test/java/com/danikula/videocache/FileCacheTest.java +++ b/test/src/test/java/com/danikula/videocache/file/FileCacheTest.java @@ -1,7 +1,9 @@ -package com.danikula.videocache; +package com.danikula.videocache.file; import com.danikula.android.garden.io.Files; import com.danikula.android.garden.io.IoUtils; +import com.danikula.videocache.Cache; +import com.danikula.videocache.ProxyCacheException; import com.danikula.videocache.test.BuildConfig; import org.junit.Assert; diff --git a/test/src/test/java/com/danikula/videocache/file/FilesTest.java b/test/src/test/java/com/danikula/videocache/file/FilesTest.java new file mode 100644 index 0000000..6686743 --- /dev/null +++ b/test/src/test/java/com/danikula/videocache/file/FilesTest.java @@ -0,0 +1,62 @@ +package com.danikula.videocache.file; + +import com.danikula.videocache.support.ProxyCacheTestUtils; +import com.danikula.videocache.test.BuildConfig; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricGradleTestRunner; +import org.robolectric.annotation.Config; + +import java.io.File; + +import static org.fest.assertions.api.Assertions.assertThat; + +/** + * Tests for {@link Files}. + * + * @author Alexey Danilov (danikula@gmail.com). + */ +@RunWith(RobolectricGradleTestRunner.class) +@Config(constants = BuildConfig.class, emulateSdk = BuildConfig.MIN_SDK_VERSION) +public class FilesTest { + + @Test + public void testModify() throws Exception { + byte[] data = ProxyCacheTestUtils.generate(999); + File file = ProxyCacheTestUtils.newCacheFile(); + com.google.common.io.Files.write(data, file); + long lastModified = file.lastModified(); + + Thread.sleep(1100); // file can store modification date in seconds. so wait for ~ 1 sec + Files.modify(file); + + assertThat(file).hasBinaryContent(data); + assertThat(file.lastModified()).isGreaterThan(lastModified); + } + + @Test + public void testSetModifiedNow() throws Exception { + File file = ProxyCacheTestUtils.newCacheFile(); + com.google.common.io.Files.write(ProxyCacheTestUtils.generate(22), file); + + Thread.sleep(1100); // file can store modification date in seconds. so wait for ~ 1 sec + long nowRoundedToSecond = System.currentTimeMillis() / 1000 * 1000; + Files.setLastModifiedNow(file); + + assertThat(file.lastModified()).isGreaterThanOrEqualTo(nowRoundedToSecond); + } + + @Test + public void testModifyZeroSizeFile() throws Exception { + File file = ProxyCacheTestUtils.newCacheFile(); + boolean created = file.createNewFile(); + assertThat(created).isTrue(); + + Thread.sleep(1100); // file can store modification date in seconds. so wait for ~ 2 sec + long nowRoundedToSecond = System.currentTimeMillis() / 1000 * 1000; + Files.modify(file); + + assertThat(file.lastModified()).isGreaterThanOrEqualTo(nowRoundedToSecond); + } +} diff --git a/test/src/test/java/com/danikula/videocache/support/AngryHttpUrlSource.java b/test/src/test/java/com/danikula/videocache/support/AngryHttpUrlSource.java index 951d725..220837f 100644 --- a/test/src/test/java/com/danikula/videocache/support/AngryHttpUrlSource.java +++ b/test/src/test/java/com/danikula/videocache/support/AngryHttpUrlSource.java @@ -8,6 +8,7 @@ import com.danikula.videocache.Source; * * @author Alexey Danilov (danikula@gmail.com). */ +@Deprecated // use Mockito to throw error public class AngryHttpUrlSource implements Source { @Override diff --git a/test/src/test/java/com/danikula/videocache/support/PhlegmaticByteArraySource.java b/test/src/test/java/com/danikula/videocache/support/PhlegmaticByteArraySource.java index dee5ac1..387c7c7 100644 --- a/test/src/test/java/com/danikula/videocache/support/PhlegmaticByteArraySource.java +++ b/test/src/test/java/com/danikula/videocache/support/PhlegmaticByteArraySource.java @@ -8,6 +8,7 @@ import java.util.Random; /** * @author Alexey Danilov (danikula@gmail.com). */ +@Deprecated // TODO: use Mockito to mock delay public class PhlegmaticByteArraySource extends ByteArraySource { private final Random delayGenerator;