1 /*
2  * Copyright (C) 2011 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.volley.toolbox;
18 
19 import androidx.annotation.Nullable;
20 import androidx.annotation.RestrictTo;
21 import androidx.annotation.RestrictTo.Scope;
22 import com.android.volley.Cache;
23 import com.android.volley.Header;
24 import com.android.volley.NetworkResponse;
25 import com.android.volley.VolleyLog;
26 import java.text.ParseException;
27 import java.text.SimpleDateFormat;
28 import java.util.ArrayList;
29 import java.util.Collections;
30 import java.util.Date;
31 import java.util.HashMap;
32 import java.util.List;
33 import java.util.Locale;
34 import java.util.Map;
35 import java.util.Set;
36 import java.util.TimeZone;
37 import java.util.TreeMap;
38 import java.util.TreeSet;
39 
40 /** Utility methods for parsing HTTP headers. */
41 public class HttpHeaderParser {
42 
43     @RestrictTo({Scope.LIBRARY_GROUP})
44     public static final String HEADER_CONTENT_TYPE = "Content-Type";
45 
46     private static final String DEFAULT_CONTENT_CHARSET = "ISO-8859-1";
47 
48     private static final String RFC1123_PARSE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss zzz";
49 
50     // Hardcode 'GMT' rather than using 'zzz' since some platforms append an extraneous +00:00.
51     // See #287.
52     private static final String RFC1123_OUTPUT_FORMAT = "EEE, dd MMM yyyy HH:mm:ss 'GMT'";
53 
54     /**
55      * Extracts a {@link com.android.volley.Cache.Entry} from a {@link NetworkResponse}.
56      *
57      * @param response The network response to parse headers from
58      * @return a cache entry for the given response, or null if the response is not cacheable.
59      */
60     @Nullable
parseCacheHeaders(NetworkResponse response)61     public static Cache.Entry parseCacheHeaders(NetworkResponse response) {
62         long now = System.currentTimeMillis();
63 
64         Map<String, String> headers = response.headers;
65         if (headers == null) {
66             return null;
67         }
68 
69         long serverDate = 0;
70         long lastModified = 0;
71         long serverExpires = 0;
72         long softExpire = 0;
73         long finalExpire = 0;
74         long maxAge = 0;
75         long staleWhileRevalidate = 0;
76         boolean hasCacheControl = false;
77         boolean mustRevalidate = false;
78 
79         String serverEtag = null;
80         String headerValue;
81 
82         headerValue = headers.get("Date");
83         if (headerValue != null) {
84             serverDate = parseDateAsEpoch(headerValue);
85         }
86 
87         headerValue = headers.get("Cache-Control");
88         if (headerValue != null) {
89             hasCacheControl = true;
90             String[] tokens = headerValue.split(",", 0);
91             for (int i = 0; i < tokens.length; i++) {
92                 String token = tokens[i].trim();
93                 if (token.equals("no-cache") || token.equals("no-store")) {
94                     return null;
95                 } else if (token.startsWith("max-age=")) {
96                     try {
97                         maxAge = Long.parseLong(token.substring(8));
98                     } catch (Exception e) {
99                     }
100                 } else if (token.startsWith("stale-while-revalidate=")) {
101                     try {
102                         staleWhileRevalidate = Long.parseLong(token.substring(23));
103                     } catch (Exception e) {
104                     }
105                 } else if (token.equals("must-revalidate") || token.equals("proxy-revalidate")) {
106                     mustRevalidate = true;
107                 }
108             }
109         }
110 
111         headerValue = headers.get("Expires");
112         if (headerValue != null) {
113             serverExpires = parseDateAsEpoch(headerValue);
114         }
115 
116         headerValue = headers.get("Last-Modified");
117         if (headerValue != null) {
118             lastModified = parseDateAsEpoch(headerValue);
119         }
120 
121         serverEtag = headers.get("ETag");
122 
123         // Cache-Control takes precedence over an Expires header, even if both exist and Expires
124         // is more restrictive.
125         if (hasCacheControl) {
126             softExpire = now + maxAge * 1000;
127             finalExpire = mustRevalidate ? softExpire : softExpire + staleWhileRevalidate * 1000;
128         } else if (serverDate > 0 && serverExpires >= serverDate) {
129             // Default semantic for Expire header in HTTP specification is softExpire.
130             softExpire = now + (serverExpires - serverDate);
131             finalExpire = softExpire;
132         }
133 
134         Cache.Entry entry = new Cache.Entry();
135         entry.data = response.data;
136         entry.etag = serverEtag;
137         entry.softTtl = softExpire;
138         entry.ttl = finalExpire;
139         entry.serverDate = serverDate;
140         entry.lastModified = lastModified;
141         entry.responseHeaders = headers;
142         entry.allResponseHeaders = response.allHeaders;
143 
144         return entry;
145     }
146 
147     /** Parse date in RFC1123 format, and return its value as epoch */
parseDateAsEpoch(String dateStr)148     public static long parseDateAsEpoch(String dateStr) {
149         try {
150             // Parse date in RFC1123 format if this header contains one
151             return newUsGmtFormatter(RFC1123_PARSE_FORMAT).parse(dateStr).getTime();
152         } catch (ParseException e) {
153             // Date in invalid format, fallback to 0
154             // If the value is either "0" or "-1" we only log to verbose,
155             // these values are pretty common and cause log spam.
156             String message = "Unable to parse dateStr: %s, falling back to 0";
157             if ("0".equals(dateStr) || "-1".equals(dateStr)) {
158                 VolleyLog.v(message, dateStr);
159             } else {
160                 VolleyLog.e(e, message, dateStr);
161             }
162 
163             return 0;
164         }
165     }
166 
167     /** Format an epoch date in RFC1123 format. */
formatEpochAsRfc1123(long epoch)168     static String formatEpochAsRfc1123(long epoch) {
169         return newUsGmtFormatter(RFC1123_OUTPUT_FORMAT).format(new Date(epoch));
170     }
171 
newUsGmtFormatter(String format)172     private static SimpleDateFormat newUsGmtFormatter(String format) {
173         SimpleDateFormat formatter = new SimpleDateFormat(format, Locale.US);
174         formatter.setTimeZone(TimeZone.getTimeZone("GMT"));
175         return formatter;
176     }
177 
178     /**
179      * Retrieve a charset from headers
180      *
181      * @param headers An {@link java.util.Map} of headers
182      * @param defaultCharset Charset to return if none can be found
183      * @return Returns the charset specified in the Content-Type of this header, or the
184      *     defaultCharset if none can be found.
185      */
parseCharset( @ullable Map<String, String> headers, String defaultCharset)186     public static String parseCharset(
187             @Nullable Map<String, String> headers, String defaultCharset) {
188         if (headers == null) {
189             return defaultCharset;
190         }
191         String contentType = headers.get(HEADER_CONTENT_TYPE);
192         if (contentType != null) {
193             String[] params = contentType.split(";", 0);
194             for (int i = 1; i < params.length; i++) {
195                 String[] pair = params[i].trim().split("=", 0);
196                 if (pair.length == 2) {
197                     if (pair[0].equals("charset")) {
198                         return pair[1];
199                     }
200                 }
201             }
202         }
203 
204         return defaultCharset;
205     }
206 
207     /**
208      * Returns the charset specified in the Content-Type of this header, or the HTTP default
209      * (ISO-8859-1) if none can be found.
210      */
parseCharset(@ullable Map<String, String> headers)211     public static String parseCharset(@Nullable Map<String, String> headers) {
212         return parseCharset(headers, DEFAULT_CONTENT_CHARSET);
213     }
214 
215     // Note - these are copied from NetworkResponse to avoid making them public (as needed to access
216     // them from the .toolbox package), which would mean they'd become part of the Volley API.
217     // TODO: Consider obfuscating official releases so we can share utility methods between Volley
218     // and Toolbox without making them public APIs.
219 
toHeaderMap(List<Header> allHeaders)220     static Map<String, String> toHeaderMap(List<Header> allHeaders) {
221         Map<String, String> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
222         // Later elements in the list take precedence.
223         for (Header header : allHeaders) {
224             headers.put(header.getName(), header.getValue());
225         }
226         return headers;
227     }
228 
toAllHeaderList(Map<String, String> headers)229     static List<Header> toAllHeaderList(Map<String, String> headers) {
230         List<Header> allHeaders = new ArrayList<>(headers.size());
231         for (Map.Entry<String, String> header : headers.entrySet()) {
232             allHeaders.add(new Header(header.getKey(), header.getValue()));
233         }
234         return allHeaders;
235     }
236 
237     /**
238      * Combine cache headers with network response headers for an HTTP 304 response.
239      *
240      * <p>An HTTP 304 response does not have all header fields. We have to use the header fields
241      * from the cache entry plus the new ones from the response. See also:
242      * http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3.5
243      *
244      * @param responseHeaders Headers from the network response.
245      * @param entry The cached response.
246      * @return The combined list of headers.
247      */
combineHeaders(List<Header> responseHeaders, Cache.Entry entry)248     static List<Header> combineHeaders(List<Header> responseHeaders, Cache.Entry entry) {
249         // First, create a case-insensitive set of header names from the network
250         // response.
251         Set<String> headerNamesFromNetworkResponse = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
252         if (!responseHeaders.isEmpty()) {
253             for (Header header : responseHeaders) {
254                 headerNamesFromNetworkResponse.add(header.getName());
255             }
256         }
257 
258         // Second, add headers from the cache entry to the network response as long as
259         // they didn't appear in the network response, which should take precedence.
260         List<Header> combinedHeaders = new ArrayList<>(responseHeaders);
261         if (entry.allResponseHeaders != null) {
262             if (!entry.allResponseHeaders.isEmpty()) {
263                 for (Header header : entry.allResponseHeaders) {
264                     if (!headerNamesFromNetworkResponse.contains(header.getName())) {
265                         combinedHeaders.add(header);
266                     }
267                 }
268             }
269         } else {
270             // Legacy caches only have entry.responseHeaders.
271             if (!entry.responseHeaders.isEmpty()) {
272                 for (Map.Entry<String, String> header : entry.responseHeaders.entrySet()) {
273                     if (!headerNamesFromNetworkResponse.contains(header.getKey())) {
274                         combinedHeaders.add(new Header(header.getKey(), header.getValue()));
275                     }
276                 }
277             }
278         }
279         return combinedHeaders;
280     }
281 
getCacheHeaders(Cache.Entry entry)282     static Map<String, String> getCacheHeaders(Cache.Entry entry) {
283         // If there's no cache entry, we're done.
284         if (entry == null) {
285             return Collections.emptyMap();
286         }
287 
288         Map<String, String> headers = new HashMap<>();
289 
290         if (entry.etag != null) {
291             headers.put("If-None-Match", entry.etag);
292         }
293 
294         if (entry.lastModified > 0) {
295             headers.put(
296                     "If-Modified-Since", HttpHeaderParser.formatEpochAsRfc1123(entry.lastModified));
297         }
298 
299         return headers;
300     }
301 }
302