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