1 package com.airbnb.lottie.network; 2 3 4 import android.util.Pair; 5 6 import androidx.annotation.NonNull; 7 import androidx.annotation.Nullable; 8 import androidx.annotation.RestrictTo; 9 import androidx.annotation.WorkerThread; 10 11 import com.airbnb.lottie.utils.Logger; 12 13 import java.io.File; 14 import java.io.FileInputStream; 15 import java.io.FileNotFoundException; 16 import java.io.FileOutputStream; 17 import java.io.IOException; 18 import java.io.InputStream; 19 import java.io.OutputStream; 20 import java.security.MessageDigest; 21 import java.security.NoSuchAlgorithmException; 22 23 /** 24 * Helper class to save and restore animations fetched from an URL to the app disk cache. 25 */ 26 @RestrictTo(RestrictTo.Scope.LIBRARY) 27 public class NetworkCache { 28 29 @NonNull 30 private final LottieNetworkCacheProvider cacheProvider; 31 NetworkCache(@onNull LottieNetworkCacheProvider cacheProvider)32 public NetworkCache(@NonNull LottieNetworkCacheProvider cacheProvider) { 33 this.cacheProvider = cacheProvider; 34 } 35 clear()36 public void clear() { 37 File parentDir = parentDir(); 38 if (parentDir.exists()) { 39 File[] files = parentDir.listFiles(); 40 if (files != null && files.length > 0) { 41 for (File file : files) { 42 file.delete(); 43 } 44 } 45 parentDir.delete(); 46 } 47 } 48 49 /** 50 * If the animation doesn't exist in the cache, null will be returned. 51 * <p> 52 * Once the animation is successfully parsed, {@link #renameTempFile(String, FileExtension)} must be 53 * called to move the file from a temporary location to its permanent cache location so it can 54 * be used in the future. 55 */ 56 @Nullable 57 @WorkerThread fetch(String url)58 Pair<FileExtension, InputStream> fetch(String url) { 59 File cachedFile; 60 try { 61 cachedFile = getCachedFile(url); 62 } catch (FileNotFoundException e) { 63 return null; 64 } 65 if (cachedFile == null) { 66 return null; 67 } 68 69 FileInputStream inputStream; 70 try { 71 inputStream = new FileInputStream(cachedFile); 72 } catch (FileNotFoundException e) { 73 return null; 74 } 75 76 FileExtension extension; 77 if (cachedFile.getAbsolutePath().endsWith(".zip")) { 78 extension = FileExtension.ZIP; 79 } else if (cachedFile.getAbsolutePath().endsWith(".gz")) { 80 extension = FileExtension.GZIP; 81 } else { 82 extension = FileExtension.JSON; 83 } 84 85 Logger.debug("Cache hit for " + url + " at " + cachedFile.getAbsolutePath()); 86 return new Pair<>(extension, (InputStream) inputStream); 87 } 88 89 /** 90 * Writes an InputStream from a network response to a temporary file. If the file successfully parses 91 * to an composition, {@link #renameTempFile(String, FileExtension)} should be called to move the file 92 * to its final location for future cache hits. 93 */ writeTempCacheFile(String url, InputStream stream, FileExtension extension)94 File writeTempCacheFile(String url, InputStream stream, FileExtension extension) throws IOException { 95 String fileName = filenameForUrl(url, extension, true); 96 File file = new File(parentDir(), fileName); 97 try { 98 OutputStream output = new FileOutputStream(file); 99 //noinspection TryFinallyCanBeTryWithResources 100 try { 101 byte[] buffer = new byte[1024]; 102 int read; 103 104 while ((read = stream.read(buffer)) != -1) { 105 output.write(buffer, 0, read); 106 } 107 108 output.flush(); 109 } finally { 110 output.close(); 111 } 112 } finally { 113 stream.close(); 114 } 115 return file; 116 } 117 118 /** 119 * If the file created by {@link #writeTempCacheFile(String, InputStream, FileExtension)} was successfully parsed, 120 * this should be called to remove the temporary part of its name which will allow it to be a cache hit in the future. 121 */ renameTempFile(String url, FileExtension extension)122 void renameTempFile(String url, FileExtension extension) { 123 String fileName = filenameForUrl(url, extension, true); 124 File file = new File(parentDir(), fileName); 125 String newFileName = file.getAbsolutePath().replace(".temp", ""); 126 File newFile = new File(newFileName); 127 boolean renamed = file.renameTo(newFile); 128 Logger.debug("Copying temp file to real file (" + newFile + ")"); 129 if (!renamed) { 130 Logger.warning("Unable to rename cache file " + file.getAbsolutePath() + " to " + newFile.getAbsolutePath() + "."); 131 } 132 } 133 134 /** 135 * Returns the cache file for the given url if it exists. Checks for both json and zip. 136 * Returns null if neither exist. 137 */ 138 @Nullable getCachedFile(String url)139 private File getCachedFile(String url) throws FileNotFoundException { 140 File jsonFile = new File(parentDir(), filenameForUrl(url, FileExtension.JSON, false)); 141 if (jsonFile.exists()) { 142 return jsonFile; 143 } 144 File zipFile = new File(parentDir(), filenameForUrl(url, FileExtension.ZIP, false)); 145 if (zipFile.exists()) { 146 return zipFile; 147 } 148 File gzipFile = new File(parentDir(), filenameForUrl(url, FileExtension.GZIP, false)); 149 if (gzipFile.exists()) { 150 return gzipFile; 151 } 152 return null; 153 } 154 parentDir()155 private File parentDir() { 156 File file = cacheProvider.getCacheDir(); 157 if (file.isFile()) { 158 file.delete(); 159 } 160 if (!file.exists()) { 161 file.mkdirs(); 162 } 163 return file; 164 } 165 filenameForUrl(String url, FileExtension extension, boolean isTemp)166 private static String filenameForUrl(String url, FileExtension extension, boolean isTemp) { 167 String prefix = "lottie_cache_"; 168 String suffix = (isTemp ? extension.tempExtension() : extension.extension); 169 String sanitizedUrl = url.replaceAll("\\W+", ""); 170 // The max filename on Android is 255 chars. 171 int maxUrlLength = 255 - prefix.length() - suffix.length(); 172 if (sanitizedUrl.length() > maxUrlLength) { 173 // If the url is too long, use md5 as the cache key instead. 174 // md5 is preferable to substring because it is impossible to know 175 // which parts of the url are significant. If it is the end chars 176 // then substring could cause multiple animations to use the same 177 // cache key. 178 // md5 is probably better for everything but: 179 // 1. It is slower and unnecessary in most cases. 180 // 2. Upon upgrading, if the cache key algorithm changes, 181 // all old cached animations will get orphaned. 182 sanitizedUrl = getMD5(sanitizedUrl, maxUrlLength); 183 } 184 185 return prefix + sanitizedUrl + suffix; 186 } 187 getMD5(String input, int maxLength)188 private static String getMD5(String input, int maxLength) { 189 MessageDigest md; 190 try { 191 md = MessageDigest.getInstance("MD5"); 192 } catch (NoSuchAlgorithmException e) { 193 // For some reason, md5 doesn't exist, return a substring. 194 // This should never happen. 195 return input.substring(0, maxLength); 196 } 197 byte[] messageDigest = md.digest(input.getBytes()); 198 StringBuilder sb = new StringBuilder(); 199 for (int i = 0; i < messageDigest.length; i++) { 200 byte b = messageDigest[i]; 201 sb.append(String.format("%02x", b)); 202 } 203 return sb.toString(); 204 } 205 } 206