diff --git a/library/src/androidTest/java/com/google/android/exoplayer/util/UtilTest.java b/library/src/androidTest/java/com/google/android/exoplayer/util/UtilTest.java index 7ffff22960..7efa0aafc8 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer/util/UtilTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer/util/UtilTest.java @@ -161,4 +161,20 @@ public class UtilTest extends TestCase { assertEquals(value, reconstructedValue); } + public void testUnescapeInvalidFileName() { + assertNull(Util.unescapeFileName("%a")); + assertNull(Util.unescapeFileName("%xyz")); + } + + public void testEscapeUnescapeFileName() { + assertEscapeUnescapeFileName("just+a regular+fileName", "just+a regular+fileName"); + assertEscapeUnescapeFileName("key:value", "key%3avalue"); + assertEscapeUnescapeFileName("<>:\"/\\|?*%", "%3c%3e%3a%22%2f%5c%7c%3f%2a%25"); + } + + private static void assertEscapeUnescapeFileName(String fileName, String escapedFileName) { + assertEquals(escapedFileName, Util.escapeFileName(fileName)); + assertEquals(fileName, Util.unescapeFileName(escapedFileName)); + } + } diff --git a/library/src/main/java/com/google/android/exoplayer/upstream/cache/CacheSpan.java b/library/src/main/java/com/google/android/exoplayer/upstream/cache/CacheSpan.java index ddde6b003c..37d620c38b 100644 --- a/library/src/main/java/com/google/android/exoplayer/upstream/cache/CacheSpan.java +++ b/library/src/main/java/com/google/android/exoplayer/upstream/cache/CacheSpan.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer.upstream.cache; +import com.google.android.exoplayer.util.Util; import java.io.File; import java.util.regex.Matcher; @@ -25,10 +26,11 @@ import java.util.regex.Pattern; */ public final class CacheSpan implements Comparable { - private static final String SUFFIX = ".v1.exo"; - private static final String SUFFIX_ESCAPED = "\\.v1\\.exo"; - private static final Pattern cacheFilePattern = - Pattern.compile("^(.+)\\.(\\d+)\\.(\\d+)(" + SUFFIX_ESCAPED + ")$"); + private static final String SUFFIX = ".v2.exo"; + private static final Pattern CACHE_FILE_PATTERN_V1 = + Pattern.compile("^(.+)\\.(\\d+)\\.(\\d+)\\.v1\\.exo$"); + private static final Pattern CACHE_FILE_PATTERN_V2 = + Pattern.compile("^(.+)\\.(\\d+)\\.(\\d+)\\.v2\\.exo$"); /** * The cache key that uniquely identifies the original stream. @@ -57,7 +59,8 @@ public final class CacheSpan implements Comparable { public static File getCacheFileName(File cacheDir, String key, long offset, long lastAccessTimestamp) { - return new File(cacheDir, key + "." + offset + "." + lastAccessTimestamp + SUFFIX); + return new File(cacheDir, + Util.escapeFileName(key) + "." + offset + "." + lastAccessTimestamp + SUFFIX); } public static CacheSpan createLookup(String key, long position) { @@ -79,12 +82,25 @@ public final class CacheSpan implements Comparable { * @return The span, or null if the file name is not correctly formatted. */ public static CacheSpan createCacheEntry(File file) { - Matcher matcher = cacheFilePattern.matcher(file.getName()); + Matcher matcher = CACHE_FILE_PATTERN_V2.matcher(file.getName()); if (!matcher.matches()) { return null; } - return CacheSpan.createCacheEntry(matcher.group(1), Long.parseLong(matcher.group(2)), - Long.parseLong(matcher.group(3)), file); + String key = Util.unescapeFileName(matcher.group(1)); + return key == null ? null : createCacheEntry( + key, Long.parseLong(matcher.group(2)), Long.parseLong(matcher.group(3)), file); + } + + static File upgradeIfNeeded(File file) { + Matcher matcher = CACHE_FILE_PATTERN_V1.matcher(file.getName()); + if (!matcher.matches()) { + return file; + } + String key = matcher.group(1); // Keys were not escaped in version 1. + File newCacheFile = getCacheFileName(file.getParentFile(), key, + Long.parseLong(matcher.group(2)), Long.parseLong(matcher.group(3))); + file.renameTo(newCacheFile); + return newCacheFile; } private static CacheSpan createCacheEntry(String key, long position, long lastAccessTimestamp, diff --git a/library/src/main/java/com/google/android/exoplayer/upstream/cache/SimpleCache.java b/library/src/main/java/com/google/android/exoplayer/upstream/cache/SimpleCache.java index 649e555dc3..86fc1b6129 100644 --- a/library/src/main/java/com/google/android/exoplayer/upstream/cache/SimpleCache.java +++ b/library/src/main/java/com/google/android/exoplayer/upstream/cache/SimpleCache.java @@ -245,6 +245,7 @@ public final class SimpleCache implements Cache { if (file.length() == 0) { file.delete(); } else { + file = CacheSpan.upgradeIfNeeded(file); CacheSpan span = CacheSpan.createCacheEntry(file); if (span == null) { file.delete(); diff --git a/library/src/main/java/com/google/android/exoplayer/util/Util.java b/library/src/main/java/com/google/android/exoplayer/util/Util.java index bd057cfbf8..73accd5ad8 100644 --- a/library/src/main/java/com/google/android/exoplayer/util/Util.java +++ b/library/src/main/java/com/google/android/exoplayer/util/Util.java @@ -114,6 +114,8 @@ public final class Util { Pattern.compile("^(-)?P(([0-9]*)Y)?(([0-9]*)M)?(([0-9]*)D)?" + "(T(([0-9]*)H)?(([0-9]*)M)?(([0-9.]*)S)?)?$"); + private static final Pattern ESCAPED_CHARACTER_PATTERN = Pattern.compile("%([A-Fa-f0-9]{2})"); + private static final long MAX_BYTES_TO_DRAIN = 2048; private Util() {} @@ -786,4 +788,103 @@ public final class Util { return TYPE_OTHER; } } + + /** + * Escapes a string so that it's safe for use as a file or directory name on at least FAT32 + * filesystems. FAT32 is the most restrictive of all filesystems still commonly used today. + * + *

For simplicity, this only handles common characters known to be illegal on FAT32: + * <, >, :, ", /, \, |, ?, and *. % is also escaped since it is used as the escape character. + * Escaping is performed in a consistent way so that no collisions occur and + * {@link #unescapeFileName(String)} can be used to retrieve the original file name. + * + * @param fileName File name to be escaped. + * @return An escaped file name which will be safe for use on at least FAT32 filesystems. + */ + public static String escapeFileName(String fileName) { + int length = fileName.length(); + int charactersToEscapeCount = 0; + for (int i = 0; i < length; i++) { + if (shouldEscapeCharacter(fileName.charAt(i))) { + charactersToEscapeCount++; + } + } + if (charactersToEscapeCount == 0) { + return fileName; + } + + int i = 0; + StringBuilder builder = new StringBuilder(length + charactersToEscapeCount * 2); + while (charactersToEscapeCount > 0) { + char c = fileName.charAt(i++); + if (shouldEscapeCharacter(c)) { + builder.append('%').append(Integer.toHexString(c)); + charactersToEscapeCount--; + } else { + builder.append(c); + } + } + if (i < length) { + builder.append(fileName, i, length); + } + return builder.toString(); + } + + private static boolean shouldEscapeCharacter(char c) { + switch (c) { + case '<': + case '>': + case ':': + case '"': + case '/': + case '\\': + case '|': + case '?': + case '*': + case '%': + return true; + default: + return false; + } + } + + /** + * Unescapes an escaped file or directory name back to its original value. + * + *

See {@link #escapeFileName(String)} for more information. + * + * @param fileName File name to be unescaped. + * @return The original value of the file name before it was escaped, + * or null if the escaped fileName seems invalid. + */ + public static String unescapeFileName(String fileName) { + int length = fileName.length(); + int percentCharacterCount = 0; + for (int i = 0; i < length; i++) { + if (fileName.charAt(i) == '%') { + percentCharacterCount++; + } + } + if (percentCharacterCount == 0) { + return fileName; + } + + int expectedLength = length - percentCharacterCount * 2; + StringBuilder builder = new StringBuilder(expectedLength); + Matcher matcher = ESCAPED_CHARACTER_PATTERN.matcher(fileName); + int endOfLastMatch = 0; + while (percentCharacterCount > 0 && matcher.find()) { + char unescapedCharacter = (char) Integer.parseInt(matcher.group(1), 16); + builder.append(fileName, endOfLastMatch, matcher.start()).append(unescapedCharacter); + endOfLastMatch = matcher.end(); + percentCharacterCount--; + } + if (endOfLastMatch < length) { + builder.append(fileName, endOfLastMatch, length); + } + if (builder.length() != expectedLength) { + return null; + } + return builder.toString(); + } }