• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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