Merge "Change the default character encoding for JSON responses to UTF-8"
diff --git a/pom.xml b/pom.xml
index 90061b1..47252cb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -32,6 +32,11 @@
       <artifactId>robolectric</artifactId>
       <version>2.2</version>
     </dependency>
+    <dependency>
+      <groupId>org.mockito</groupId>
+       <artifactId>mockito-core</artifactId>
+       <version>1.9.5</version>
+    </dependency>
   </dependencies>
 
   <build>
@@ -57,6 +62,13 @@
             <target>${java.version}</target>
           </configuration>
         </plugin>
+
+	<plugin>
+          <groupId>org.codehaus.mojo</groupId>
+          <artifactId>emma-maven-plugin</artifactId>
+          <version>1.0-alpha-3</version>
+        </plugin>
+
       </plugins>
     </pluginManagement>
   </build>
diff --git a/src/main/java/com/android/volley/Cache.java b/src/main/java/com/android/volley/Cache.java
index eafd118..f1ec757 100644
--- a/src/main/java/com/android/volley/Cache.java
+++ b/src/main/java/com/android/volley/Cache.java
@@ -74,6 +74,9 @@
         /** Date of this response as reported by the server. */
         public long serverDate;
 
+        /** The last modified date for the requested object. */
+        public long lastModified;
+
         /** TTL for this record. */
         public long ttl;
 
diff --git a/src/main/java/com/android/volley/toolbox/AndroidAuthenticator.java b/src/main/java/com/android/volley/toolbox/AndroidAuthenticator.java
index 371fd83..bdf7091 100644
--- a/src/main/java/com/android/volley/toolbox/AndroidAuthenticator.java
+++ b/src/main/java/com/android/volley/toolbox/AndroidAuthenticator.java
@@ -30,7 +30,7 @@
  * tokens of a specified type for a specified account.
  */
 public class AndroidAuthenticator implements Authenticator {
-    private final Context mContext;
+    private final AccountManager mAccountManager;
     private final Account mAccount;
     private final String mAuthTokenType;
     private final boolean mNotifyAuthFailure;
@@ -54,7 +54,13 @@
      */
     public AndroidAuthenticator(Context context, Account account, String authTokenType,
             boolean notifyAuthFailure) {
-        mContext = context;
+        this(AccountManager.get(context), account, authTokenType, notifyAuthFailure);
+    }
+
+    // Visible for testing. Allows injection of a mock AccountManager.
+    AndroidAuthenticator(AccountManager accountManager, Account account,
+            String authTokenType, boolean notifyAuthFailure) {
+        mAccountManager = accountManager;
         mAccount = account;
         mAuthTokenType = authTokenType;
         mNotifyAuthFailure = notifyAuthFailure;
@@ -71,8 +77,7 @@
     @SuppressWarnings("deprecation")
     @Override
     public String getAuthToken() throws AuthFailureError {
-        final AccountManager accountManager = AccountManager.get(mContext);
-        AccountManagerFuture<Bundle> future = accountManager.getAuthToken(mAccount,
+        AccountManagerFuture<Bundle> future = mAccountManager.getAuthToken(mAccount,
                 mAuthTokenType, mNotifyAuthFailure, null, null);
         Bundle result;
         try {
@@ -97,6 +102,6 @@
 
     @Override
     public void invalidateAuthToken(String authToken) {
-        AccountManager.get(mContext).invalidateAuthToken(mAccount.type, authToken);
+        mAccountManager.invalidateAuthToken(mAccount.type, authToken);
     }
 }
diff --git a/src/main/java/com/android/volley/toolbox/BasicNetwork.java b/src/main/java/com/android/volley/toolbox/BasicNetwork.java
index bc1cfdb..4b1603b 100644
--- a/src/main/java/com/android/volley/toolbox/BasicNetwork.java
+++ b/src/main/java/com/android/volley/toolbox/BasicNetwork.java
@@ -212,8 +212,8 @@
             headers.put("If-None-Match", entry.etag);
         }
 
-        if (entry.serverDate > 0) {
-            Date refTime = new Date(entry.serverDate);
+        if (entry.lastModified > 0) {
+            Date refTime = new Date(entry.lastModified);
             headers.put("If-Modified-Since", DateUtils.formatDate(refTime));
         }
     }
diff --git a/src/main/java/com/android/volley/toolbox/DiskBasedCache.java b/src/main/java/com/android/volley/toolbox/DiskBasedCache.java
index b283788..036b55a 100644
--- a/src/main/java/com/android/volley/toolbox/DiskBasedCache.java
+++ b/src/main/java/com/android/volley/toolbox/DiskBasedCache.java
@@ -349,6 +349,9 @@
         /** Date of this response as reported by the server. */
         public long serverDate;
 
+        /** The last modified date for the requested object. */
+        public long lastModified;
+
         /** TTL for this record. */
         public long ttl;
 
@@ -370,6 +373,7 @@
             this.size = entry.data.length;
             this.etag = entry.etag;
             this.serverDate = entry.serverDate;
+            this.lastModified = entry.lastModified;
             this.ttl = entry.ttl;
             this.softTtl = entry.softTtl;
             this.responseHeaders = entry.responseHeaders;
@@ -396,6 +400,13 @@
             entry.ttl = readLong(is);
             entry.softTtl = readLong(is);
             entry.responseHeaders = readStringStringMap(is);
+
+            try {
+                entry.lastModified = readLong(is);
+            } catch (EOFException e) {
+                // the old cache entry format doesn't know lastModified
+            }
+
             return entry;
         }
 
@@ -407,6 +418,7 @@
             e.data = data;
             e.etag = etag;
             e.serverDate = serverDate;
+            e.lastModified = lastModified;
             e.ttl = ttl;
             e.softTtl = softTtl;
             e.responseHeaders = responseHeaders;
@@ -426,6 +438,7 @@
                 writeLong(os, ttl);
                 writeLong(os, softTtl);
                 writeStringStringMap(responseHeaders, os);
+                writeLong(os, lastModified);
                 os.flush();
                 return true;
             } catch (IOException e) {
diff --git a/src/main/java/com/android/volley/toolbox/HttpHeaderParser.java b/src/main/java/com/android/volley/toolbox/HttpHeaderParser.java
index 601ac0f..7306052 100644
--- a/src/main/java/com/android/volley/toolbox/HttpHeaderParser.java
+++ b/src/main/java/com/android/volley/toolbox/HttpHeaderParser.java
@@ -42,9 +42,12 @@
         Map<String, String> headers = response.headers;
 
         long serverDate = 0;
+        long lastModified = 0;
         long serverExpires = 0;
         long softExpire = 0;
+        long finalExpire = 0;
         long maxAge = 0;
+        long staleWhileRevalidate = 0;
         boolean hasCacheControl = false;
 
         String serverEtag = null;
@@ -68,6 +71,11 @@
                         maxAge = Long.parseLong(token.substring(8));
                     } catch (Exception e) {
                     }
+                } else if (token.startsWith("stale-while-revalidate=")) {
+                    try {
+                        staleWhileRevalidate = Long.parseLong(token.substring(23));
+                    } catch (Exception e) {
+                    }
                 } else if (token.equals("must-revalidate") || token.equals("proxy-revalidate")) {
                     maxAge = 0;
                 }
@@ -79,23 +87,31 @@
             serverExpires = parseDateAsEpoch(headerValue);
         }
 
+        headerValue = headers.get("Last-Modified");
+        if (headerValue != null) {
+            lastModified = parseDateAsEpoch(headerValue);
+        }
+
         serverEtag = headers.get("ETag");
 
         // Cache-Control takes precedence over an Expires header, even if both exist and Expires
         // is more restrictive.
         if (hasCacheControl) {
             softExpire = now + maxAge * 1000;
+            finalExpire = softExpire + staleWhileRevalidate * 1000;
         } else if (serverDate > 0 && serverExpires >= serverDate) {
             // Default semantic for Expire header in HTTP specification is softExpire.
             softExpire = now + (serverExpires - serverDate);
+            finalExpire = softExpire;
         }
 
         Cache.Entry entry = new Cache.Entry();
         entry.data = response.data;
         entry.etag = serverEtag;
         entry.softTtl = softExpire;
-        entry.ttl = entry.softTtl;
+        entry.ttl = finalExpire;
         entry.serverDate = serverDate;
+        entry.lastModified = lastModified;
         entry.responseHeaders = headers;
 
         return entry;
diff --git a/src/main/java/com/android/volley/toolbox/ImageLoader.java b/src/main/java/com/android/volley/toolbox/ImageLoader.java
index 5348dc6..151e022 100644
--- a/src/main/java/com/android/volley/toolbox/ImageLoader.java
+++ b/src/main/java/com/android/volley/toolbox/ImageLoader.java
@@ -20,6 +20,7 @@
 import android.os.Handler;
 import android.os.Looper;
 import android.widget.ImageView;
+import android.widget.ImageView.ScaleType;
 
 import com.android.volley.Request;
 import com.android.volley.RequestQueue;
@@ -171,6 +172,15 @@
     }
 
     /**
+     * Equivalent to calling {@link #get(String, ImageListener, int, int, ScaleType)} with
+     * {@code Scaletype == ScaleType.CENTER_INSIDE}.
+     */
+    public ImageContainer get(String requestUrl, ImageListener imageListener,
+            int maxWidth, int maxHeight) {
+        return get(requestUrl, imageListener, maxWidth, maxHeight, ScaleType.CENTER_INSIDE);
+    }
+
+    /**
      * Issues a bitmap request with the given URL if that image is not available
      * in the cache, and returns a bitmap container that contains all of the data
      * relating to the request (as well as the default image if the requested
@@ -179,11 +189,13 @@
      * @param imageListener The listener to call when the remote image is loaded
      * @param maxWidth The maximum width of the returned image.
      * @param maxHeight The maximum height of the returned image.
+     * @param scaleType The ImageViews ScaleType used to calculate the needed image size.
      * @return A container object that contains all of the properties of the request, as well as
      *     the currently available image (default if remote is not loaded).
      */
     public ImageContainer get(String requestUrl, ImageListener imageListener,
-            int maxWidth, int maxHeight) {
+            int maxWidth, int maxHeight, ScaleType scaleType) {
+        
         // only fulfill requests that were initiated from the main thread.
         throwIfNotOnMainThread();
 
@@ -215,7 +227,8 @@
 
         // The request is not already in flight. Send the new request to the network and
         // track it.
-        Request<Bitmap> newRequest = makeImageRequest(requestUrl, maxWidth, maxHeight, cacheKey);
+        Request<Bitmap> newRequest = makeImageRequest(requestUrl, maxWidth, maxHeight, scaleType,
+                cacheKey);
 
         mRequestQueue.add(newRequest);
         mInFlightRequests.put(cacheKey,
@@ -223,14 +236,14 @@
         return imageContainer;
     }
 
-    protected Request<Bitmap> makeImageRequest(String requestUrl, int maxWidth, int maxHeight, final String cacheKey) {
+    protected Request<Bitmap> makeImageRequest(String requestUrl, int maxWidth, int maxHeight,
+            ScaleType scaleType, final String cacheKey) {
         return new ImageRequest(requestUrl, new Listener<Bitmap>() {
             @Override
             public void onResponse(Bitmap response) {
                 onGetImageSuccess(cacheKey, response);
             }
-        }, maxWidth, maxHeight,
-        Config.RGB_565, new ErrorListener() {
+        }, maxWidth, maxHeight, scaleType, Config.RGB_565, new ErrorListener() {
             @Override
             public void onErrorResponse(VolleyError error) {
                 onGetImageError(cacheKey, error);
diff --git a/src/main/java/com/android/volley/toolbox/ImageRequest.java b/src/main/java/com/android/volley/toolbox/ImageRequest.java
index 2ebe015..27c1fe2 100644
--- a/src/main/java/com/android/volley/toolbox/ImageRequest.java
+++ b/src/main/java/com/android/volley/toolbox/ImageRequest.java
@@ -26,6 +26,7 @@
 import android.graphics.Bitmap;
 import android.graphics.Bitmap.Config;
 import android.graphics.BitmapFactory;
+import android.widget.ImageView.ScaleType;
 
 /**
  * A canned request for getting an image at a given URL and calling
@@ -45,6 +46,7 @@
     private final Config mDecodeConfig;
     private final int mMaxWidth;
     private final int mMaxHeight;
+    private ScaleType mScaleType;
 
     /** Decoding lock so that we don't decode more than one image at a time (to avoid OOM's) */
     private static final Object sDecodeLock = new Object();
@@ -63,20 +65,32 @@
      * @param maxWidth Maximum width to decode this bitmap to, or zero for none
      * @param maxHeight Maximum height to decode this bitmap to, or zero for
      *            none
+     * @param scaleType The ImageViews ScaleType used to calculate the needed image size.
      * @param decodeConfig Format to decode the bitmap to
      * @param errorListener Error listener, or null to ignore errors
      */
     public ImageRequest(String url, Response.Listener<Bitmap> listener, int maxWidth, int maxHeight,
-            Config decodeConfig, Response.ErrorListener errorListener) {
-        super(Method.GET, url, errorListener);
+            ScaleType scaleType, Config decodeConfig, Response.ErrorListener errorListener) {
+        super(Method.GET, url, errorListener); 
         setRetryPolicy(
                 new DefaultRetryPolicy(IMAGE_TIMEOUT_MS, IMAGE_MAX_RETRIES, IMAGE_BACKOFF_MULT));
         mListener = listener;
         mDecodeConfig = decodeConfig;
         mMaxWidth = maxWidth;
         mMaxHeight = maxHeight;
+        mScaleType = scaleType;
     }
 
+    /**
+     * For API compatibility with the pre-ScaleType variant of the constructor. Equivalent to
+     * the normal constructor with {@code ScaleType.CENTER_INSIDE}.
+     */
+    @Deprecated
+    public ImageRequest(String url, Response.Listener<Bitmap> listener, int maxWidth, int maxHeight,
+            Config decodeConfig, Response.ErrorListener errorListener) {
+        this(url, listener, maxWidth, maxHeight,
+                ScaleType.CENTER_INSIDE, decodeConfig, errorListener);
+    }
     @Override
     public Priority getPriority() {
         return Priority.LOW;
@@ -92,14 +106,24 @@
      *        maintain aspect ratio with primary dimension
      * @param actualPrimary Actual size of the primary dimension
      * @param actualSecondary Actual size of the secondary dimension
+     * @param scaleType The ScaleType used to calculate the needed image size.
      */
     private static int getResizedDimension(int maxPrimary, int maxSecondary, int actualPrimary,
-            int actualSecondary) {
+            int actualSecondary, ScaleType scaleType) {
+
         // If no dominant value at all, just return the actual.
-        if (maxPrimary == 0 && maxSecondary == 0) {
+        if ((maxPrimary == 0) && (maxSecondary == 0)) {
             return actualPrimary;
         }
 
+        // If ScaleType.FIT_XY fill the whole rectangle, ignore ratio.
+        if (scaleType == ScaleType.FIT_XY) {
+            if (maxPrimary == 0) {
+                return actualPrimary;
+            }
+            return maxPrimary;
+        }
+
         // If primary is unspecified, scale primary to match secondary's scaling ratio.
         if (maxPrimary == 0) {
             double ratio = (double) maxSecondary / (double) actualSecondary;
@@ -112,7 +136,16 @@
 
         double ratio = (double) actualSecondary / (double) actualPrimary;
         int resized = maxPrimary;
-        if (resized * ratio > maxSecondary) {
+
+        // If ScaleType.CENTER_CROP fill the whole rectangle, preserve aspect ratio.
+        if (scaleType == ScaleType.CENTER_CROP) {
+            if ((resized * ratio) < maxSecondary) {
+                resized = (int) (maxSecondary / ratio);
+            }
+            return resized;
+        }
+
+        if ((resized * ratio) > maxSecondary) {
             resized = (int) (maxSecondary / ratio);
         }
         return resized;
@@ -150,9 +183,9 @@
 
             // Then compute the dimensions we would ideally like to decode to.
             int desiredWidth = getResizedDimension(mMaxWidth, mMaxHeight,
-                    actualWidth, actualHeight);
+                    actualWidth, actualHeight, mScaleType);
             int desiredHeight = getResizedDimension(mMaxHeight, mMaxWidth,
-                    actualHeight, actualWidth);
+                    actualHeight, actualWidth, mScaleType);
 
             // Decode to the nearest power of two scaling factor.
             decodeOptions.inJustDecodeBounds = false;
diff --git a/src/main/java/com/android/volley/toolbox/NetworkImageView.java b/src/main/java/com/android/volley/toolbox/NetworkImageView.java
index 692e988..324dbc0 100644
--- a/src/main/java/com/android/volley/toolbox/NetworkImageView.java
+++ b/src/main/java/com/android/volley/toolbox/NetworkImageView.java
@@ -103,6 +103,7 @@
     void loadImageIfNecessary(final boolean isInLayoutPass) {
         int width = getWidth();
         int height = getHeight();
+        ScaleType scaleType = getScaleType();
 
         boolean wrapWidth = false, wrapHeight = false;
         if (getLayoutParams() != null) {
@@ -177,7 +178,7 @@
                             setImageResource(mDefaultImageId);
                         }
                     }
-                }, maxWidth, maxHeight);
+                }, maxWidth, maxHeight, scaleType);
 
         // update the ImageContainer to be the new bitmap container.
         mImageContainer = newContainer;
diff --git a/src/test/java/com/android/volley/toolbox/AndroidAuthenticatorTest.java b/src/test/java/com/android/volley/toolbox/AndroidAuthenticatorTest.java
new file mode 100644
index 0000000..e878658
--- /dev/null
+++ b/src/test/java/com/android/volley/toolbox/AndroidAuthenticatorTest.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.volley.toolbox;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.accounts.AccountManagerFuture;
+import android.accounts.AuthenticatorException;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import com.android.volley.AuthFailureError;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+
+import static org.mockito.Mockito.*;
+
+@RunWith(RobolectricTestRunner.class)
+public class AndroidAuthenticatorTest {
+    private AccountManager mAccountManager;
+    private Account mAccount;
+    private AccountManagerFuture<Bundle> mFuture;
+    private AndroidAuthenticator mAuthenticator;
+
+    @Before
+    public void setUp() {
+        mAccountManager = mock(AccountManager.class);
+        mFuture = mock(AccountManagerFuture.class);
+        mAccount = new Account("coolperson", "cooltype");
+        mAuthenticator = new AndroidAuthenticator(mAccountManager, mAccount, "cooltype", false);
+    }
+
+    @Test(expected = AuthFailureError.class)
+    public void failedGetAuthToken() throws Exception {
+        when(mAccountManager.getAuthToken(mAccount, "cooltype", false, null, null)).thenReturn(mFuture);
+        when(mFuture.getResult()).thenThrow(new AuthenticatorException("sadness!"));
+        mAuthenticator.getAuthToken();
+    }
+
+    @Test(expected = AuthFailureError.class)
+    public void resultContainsIntent() throws Exception {
+        Intent intent = new Intent();
+        Bundle bundle = new Bundle();
+        bundle.putParcelable(AccountManager.KEY_INTENT, intent);
+        when(mAccountManager.getAuthToken(mAccount, "cooltype", false, null, null)).thenReturn(mFuture);
+        when(mFuture.getResult()).thenReturn(bundle);
+        when(mFuture.isDone()).thenReturn(true);
+        when(mFuture.isCancelled()).thenReturn(false);
+        mAuthenticator.getAuthToken();
+    }
+
+    @Test(expected = AuthFailureError.class)
+    public void missingAuthToken() throws Exception {
+        Bundle bundle = new Bundle();
+        when(mAccountManager.getAuthToken(mAccount, "cooltype", false, null, null)).thenReturn(mFuture);
+        when(mFuture.getResult()).thenReturn(bundle);
+        when(mFuture.isDone()).thenReturn(true);
+        when(mFuture.isCancelled()).thenReturn(false);
+        mAuthenticator.getAuthToken();
+    }
+
+    @Test
+    public void invalidateAuthToken() throws Exception {
+        mAuthenticator.invalidateAuthToken("monkey");
+        verify(mAccountManager).invalidateAuthToken("cooltype", "monkey");
+    }
+
+    @Test
+    public void goodToken() throws Exception {
+        Bundle bundle = new Bundle();
+        bundle.putString(AccountManager.KEY_AUTHTOKEN, "monkey");
+        when(mAccountManager.getAuthToken(mAccount, "cooltype", false, null, null)).thenReturn(mFuture);
+        when(mFuture.getResult()).thenReturn(bundle);
+        when(mFuture.isDone()).thenReturn(true);
+        when(mFuture.isCancelled()).thenReturn(false);
+        Assert.assertEquals("monkey", mAuthenticator.getAuthToken());
+    }
+
+    @Test
+    public void publicMethods() throws Exception {
+        // Catch-all test to find API-breaking changes.
+        Context context = mock(Context.class);
+        new AndroidAuthenticator(context, mAccount, "cooltype");
+        new AndroidAuthenticator(context, mAccount, "cooltype", true);
+        Assert.assertSame(mAccount, mAuthenticator.getAccount());
+    }
+}
diff --git a/src/test/java/com/android/volley/toolbox/DiskBasedCacheTest.java b/src/test/java/com/android/volley/toolbox/DiskBasedCacheTest.java
index 4b2955d..d9d49e9 100644
--- a/src/test/java/com/android/volley/toolbox/DiskBasedCacheTest.java
+++ b/src/test/java/com/android/volley/toolbox/DiskBasedCacheTest.java
@@ -34,6 +34,7 @@
         Cache.Entry e = new Cache.Entry();
         e.data = new byte[8];
         e.serverDate = 1234567L;
+        e.lastModified = 13572468L;
         e.ttl = 9876543L;
         e.softTtl = 8765432L;
         e.etag = "etag";
@@ -48,6 +49,7 @@
 
         assertEquals(first.key, second.key);
         assertEquals(first.serverDate, second.serverDate);
+        assertEquals(first.lastModified, second.lastModified);
         assertEquals(first.ttl, second.ttl);
         assertEquals(first.softTtl, second.softTtl);
         assertEquals(first.etag, second.etag);
@@ -121,4 +123,43 @@
         assertEquals(DiskBasedCache.readStringStringMap(bais), emptyKey);
         assertEquals(DiskBasedCache.readStringStringMap(bais), emptyValue);
     }
+
+    // Test deserializing the old format into the new one.
+    public void testCacheHeaderSerializationOldToNewFormat() throws Exception {
+
+        final int CACHE_MAGIC = 0x20140623;
+        final String key = "key";
+        final String etag = "etag";
+        final long serverDate = 1234567890l;
+        final long ttl = 1357924680l;
+        final long softTtl = 2468013579l;
+
+        Map<String, String> responseHeaders = new HashMap<String, String>();
+        responseHeaders.put("first", "thing");
+        responseHeaders.put("second", "item");
+
+        // write old sytle header (without lastModified)
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        DiskBasedCache.writeInt(baos, CACHE_MAGIC);
+        DiskBasedCache.writeString(baos, key);
+        DiskBasedCache.writeString(baos, etag == null ? "" : etag);
+        DiskBasedCache.writeLong(baos, serverDate);
+        DiskBasedCache.writeLong(baos, ttl);
+        DiskBasedCache.writeLong(baos, softTtl);
+        DiskBasedCache.writeStringStringMap(responseHeaders, baos);
+
+        // read / test new style header (with lastModified)
+        ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
+        CacheHeader cacheHeader = CacheHeader.readHeader(bais);
+
+        assertEquals(cacheHeader.key, key);
+        assertEquals(cacheHeader.etag, etag);
+        assertEquals(cacheHeader.serverDate, serverDate);
+        assertEquals(cacheHeader.ttl, ttl);
+        assertEquals(cacheHeader.softTtl, softTtl);
+        assertEquals(cacheHeader.responseHeaders, responseHeaders);
+
+        // the old format doesn't know lastModified
+        assertEquals(cacheHeader.lastModified, 0);
+    }
 }
diff --git a/src/test/java/com/android/volley/toolbox/HttpHeaderParserTest.java b/src/test/java/com/android/volley/toolbox/HttpHeaderParserTest.java
index ae8257a..f9230c6 100644
--- a/src/test/java/com/android/volley/toolbox/HttpHeaderParserTest.java
+++ b/src/test/java/com/android/volley/toolbox/HttpHeaderParserTest.java
@@ -40,6 +40,7 @@
 
     private static long ONE_MINUTE_MILLIS = 1000L * 60;
     private static long ONE_HOUR_MILLIS = 1000L * 60 * 60;
+    private static long ONE_DAY_MILLIS = ONE_HOUR_MILLIS * 24;
 
     private NetworkResponse response;
     private Map<String, String> headers;
@@ -55,6 +56,7 @@
         assertNotNull(entry);
         assertNull(entry.etag);
         assertEquals(0, entry.serverDate);
+        assertEquals(0, entry.lastModified);
         assertEquals(0, entry.ttl);
         assertEquals(0, entry.softTtl);
     }
@@ -82,6 +84,7 @@
     @Test public void parseCacheHeaders_normalExpire() {
         long now = System.currentTimeMillis();
         headers.put("Date", rfc1123Date(now));
+        headers.put("Last-Modified", rfc1123Date(now - ONE_DAY_MILLIS));
         headers.put("Expires", rfc1123Date(now + ONE_HOUR_MILLIS));
 
         Cache.Entry entry = HttpHeaderParser.parseCacheHeaders(response);
@@ -89,6 +92,7 @@
         assertNotNull(entry);
         assertNull(entry.etag);
         assertEqualsWithin(entry.serverDate, now, ONE_MINUTE_MILLIS);
+        assertEqualsWithin(entry.lastModified, (now - ONE_DAY_MILLIS), ONE_MINUTE_MILLIS);
         assertTrue(entry.softTtl >= (now + ONE_HOUR_MILLIS));
         assertTrue(entry.ttl == entry.softTtl);
     }
@@ -135,6 +139,24 @@
         assertEquals(entry.softTtl, entry.ttl);
     }
 
+    @Test public void testParseCacheHeaders_staleWhileRevalidate() {
+        long now = System.currentTimeMillis();
+        headers.put("Date", rfc1123Date(now));
+        headers.put("Expires", rfc1123Date(now + ONE_HOUR_MILLIS));
+
+        // - max-age (entry.softTtl) indicates that the asset is fresh for 1 day
+        // - stale-while-revalidate (entry.ttl) indicates that the asset may
+        // continue to be served stale for up to additional 7 days
+        headers.put("Cache-Control", "max-age=86400, stale-while-revalidate=604800");
+
+        Cache.Entry entry = HttpHeaderParser.parseCacheHeaders(response);
+
+        assertNotNull(entry);
+        assertNull(entry.etag);
+        assertEqualsWithin(now + 24 * ONE_HOUR_MILLIS, entry.softTtl, ONE_MINUTE_MILLIS);
+        assertEqualsWithin(now + 8 * 24 * ONE_HOUR_MILLIS, entry.ttl, ONE_MINUTE_MILLIS);
+    }
+
     @Test public void parseCacheHeaders_cacheControlNoCache() {
         long now = System.currentTimeMillis();
         headers.put("Date", rfc1123Date(now));
diff --git a/src/test/java/com/android/volley/toolbox/ImageLoaderTest.java b/src/test/java/com/android/volley/toolbox/ImageLoaderTest.java
new file mode 100644
index 0000000..47c55a6
--- /dev/null
+++ b/src/test/java/com/android/volley/toolbox/ImageLoaderTest.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.volley.toolbox;
+
+import android.graphics.Bitmap;
+import com.android.volley.Request;
+import com.android.volley.RequestQueue;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import static org.mockito.Mockito.*;
+
+@RunWith(RobolectricTestRunner.class)
+public class ImageLoaderTest {
+    private RequestQueue mRequestQueue;
+    private ImageLoader.ImageCache mImageCache;
+    private ImageLoader mImageLoader;
+
+    @Before
+    public void setUp() {
+        mRequestQueue = mock(RequestQueue.class);
+        mImageCache = mock(ImageLoader.ImageCache.class);
+        mImageLoader = new ImageLoader(mRequestQueue, mImageCache);
+    }
+
+    @Test
+    public void isCachedChecksCache() throws Exception {
+        when(mImageCache.getBitmap(anyString())).thenReturn(null);
+        Assert.assertFalse(mImageLoader.isCached("http://foo", 0, 0));
+    }
+
+    @Test
+    public void getWithCacheHit() throws Exception {
+        Bitmap bitmap = Bitmap.createBitmap(1, 1, null);
+        ImageLoader.ImageListener listener = mock(ImageLoader.ImageListener.class);
+        when(mImageCache.getBitmap(anyString())).thenReturn(bitmap);
+        ImageLoader.ImageContainer ic = mImageLoader.get("http://foo", listener);
+        Assert.assertSame(bitmap, ic.getBitmap());
+        verify(listener).onResponse(ic, true);
+    }
+
+    @Test
+    public void getWithCacheMiss() throws Exception {
+        when(mImageCache.getBitmap(anyString())).thenReturn(null);
+        ImageLoader.ImageListener listener = mock(ImageLoader.ImageListener.class);
+        // Ask for the image to be loaded.
+        mImageLoader.get("http://foo", listener);
+        // Second pass to test deduping logic.
+        mImageLoader.get("http://foo", listener);
+        // Response callback should be called both times.
+        verify(listener, times(2)).onResponse(any(ImageLoader.ImageContainer.class), eq(true));
+        // But request should be enqueued only once.
+        verify(mRequestQueue, times(1)).add(any(Request.class));
+    }
+
+    @Test
+    public void publicMethods() throws Exception {
+        // Catch API breaking changes.
+        ImageLoader.getImageListener(null, -1, -1);
+        mImageLoader.setBatchedResponseDelay(1000);
+    }
+}
+
diff --git a/src/test/java/com/android/volley/toolbox/ImageRequestTest.java b/src/test/java/com/android/volley/toolbox/ImageRequestTest.java
index 2f4495a..bd98e7d 100644
--- a/src/test/java/com/android/volley/toolbox/ImageRequestTest.java
+++ b/src/test/java/com/android/volley/toolbox/ImageRequestTest.java
@@ -18,7 +18,7 @@
 
 import android.graphics.Bitmap;
 import android.graphics.Bitmap.Config;
-
+import android.widget.ImageView.ScaleType;
 import com.android.volley.NetworkResponse;
 import com.android.volley.Response;
 import org.junit.Test;
@@ -47,30 +47,84 @@
         ShadowBitmapFactory.provideWidthAndHeightHints("fake", 1024, 500);
         NetworkResponse jpeg = new NetworkResponse(jpegBytes);
 
-        // No resize
-        verifyResize(jpeg, 0, 0, 1024, 500);
+        // Scale the image uniformly (maintain the image's aspect ratio) so that
+        // both dimensions (width and height) of the image will be equal to or
+        // less than the corresponding dimension of the view.
+        ScaleType scalteType = ScaleType.CENTER_INSIDE;
 
         // Exact sizes
-        verifyResize(jpeg, 512, 250, 512, 250); // exactly half
-        verifyResize(jpeg, 511, 249, 509, 249); // just under half
-        verifyResize(jpeg, 1080, 500, 1024, 500); // larger
-        verifyResize(jpeg, 500, 500, 500, 244); // keep same ratio
+        verifyResize(jpeg, 512, 250, scalteType, 512, 250); // exactly half
+        verifyResize(jpeg, 511, 249, scalteType, 509, 249); // just under half
+        verifyResize(jpeg, 1080, 500, scalteType, 1024, 500); // larger
+        verifyResize(jpeg, 500, 500, scalteType, 500, 244); // keep same ratio
 
         // Specify only width, preserve aspect ratio
-        verifyResize(jpeg, 512, 0, 512, 250);
-        verifyResize(jpeg, 800, 0, 800, 390);
-        verifyResize(jpeg, 1024, 0, 1024, 500);
+        verifyResize(jpeg, 512, 0, scalteType, 512, 250);
+        verifyResize(jpeg, 800, 0, scalteType, 800, 390);
+        verifyResize(jpeg, 1024, 0, scalteType, 1024, 500);
 
         // Specify only height, preserve aspect ratio
-        verifyResize(jpeg, 0, 250, 512, 250);
-        verifyResize(jpeg, 0, 391, 800, 391);
-        verifyResize(jpeg, 0, 500, 1024, 500);
+        verifyResize(jpeg, 0, 250, scalteType, 512, 250);
+        verifyResize(jpeg, 0, 391, scalteType, 800, 391);
+        verifyResize(jpeg, 0, 500, scalteType, 1024, 500);
+
+        // No resize
+        verifyResize(jpeg, 0, 0, scalteType, 1024, 500);
+
+
+        // Scale the image uniformly (maintain the image's aspect ratio) so that
+        // both dimensions (width and height) of the image will be equal to or
+        // larger than the corresponding dimension of the view.
+        scalteType = ScaleType.CENTER_CROP;
+
+        // Exact sizes
+        verifyResize(jpeg, 512, 250, scalteType, 512, 250);
+        verifyResize(jpeg, 511, 249, scalteType, 511, 249);
+        verifyResize(jpeg, 1080, 500, scalteType, 1024, 500);
+        verifyResize(jpeg, 500, 500, scalteType, 1024, 500);
+
+        // Specify only width
+        verifyResize(jpeg, 512, 0, scalteType, 512, 250);
+        verifyResize(jpeg, 800, 0, scalteType, 800, 390);
+        verifyResize(jpeg, 1024, 0, scalteType, 1024, 500);
+
+        // Specify only height
+        verifyResize(jpeg, 0, 250, scalteType, 512, 250);
+        verifyResize(jpeg, 0, 391, scalteType, 800, 391);
+        verifyResize(jpeg, 0, 500, scalteType, 1024, 500);
+
+        // No resize
+        verifyResize(jpeg, 0, 0, scalteType, 1024, 500);
+
+
+        // Scale in X and Y independently, so that src matches dst exactly. This
+        // may change the aspect ratio of the src.
+        scalteType = ScaleType.FIT_XY;
+
+        // Exact sizes
+        verifyResize(jpeg, 512, 250, scalteType, 512, 250);
+        verifyResize(jpeg, 511, 249, scalteType, 511, 249);
+        verifyResize(jpeg, 1080, 500, scalteType, 1024, 500);
+        verifyResize(jpeg, 500, 500, scalteType, 500, 500);
+
+        // Specify only width
+        verifyResize(jpeg, 512, 0, scalteType, 512, 500);
+        verifyResize(jpeg, 800, 0, scalteType, 800, 500);
+        verifyResize(jpeg, 1024, 0, scalteType, 1024, 500);
+
+        // Specify only height
+        verifyResize(jpeg, 0, 250, scalteType, 1024, 250);
+        verifyResize(jpeg, 0, 391, scalteType, 1024, 391);
+        verifyResize(jpeg, 0, 500, scalteType, 1024, 500);
+
+        // No resize
+        verifyResize(jpeg, 0, 0, scalteType, 1024, 500);
     }
 
     private void verifyResize(NetworkResponse networkResponse, int maxWidth, int maxHeight,
-            int expectedWidth, int expectedHeight) {
-        ImageRequest request = new ImageRequest(
-                "", null, maxWidth, maxHeight, Config.RGB_565, null);
+                              ScaleType scaleType, int expectedWidth, int expectedHeight) {
+        ImageRequest request = new ImageRequest("", null, maxWidth, maxHeight, scaleType,
+                Config.RGB_565, null);
         Response<Bitmap> response = request.parseNetworkResponse(networkResponse);
         assertNotNull(response);
         assertTrue(response.isSuccess());
diff --git a/src/test/java/com/android/volley/toolbox/NetworkImageViewTest.java b/src/test/java/com/android/volley/toolbox/NetworkImageViewTest.java
index 48c81b6..bc2cc29 100644
--- a/src/test/java/com/android/volley/toolbox/NetworkImageViewTest.java
+++ b/src/test/java/com/android/volley/toolbox/NetworkImageViewTest.java
@@ -1,6 +1,7 @@
 package com.android.volley.toolbox;
 
 import android.view.ViewGroup.LayoutParams;
+import android.widget.ImageView.ScaleType;
 
 import org.junit.Before;
 import org.junit.Test;
@@ -43,7 +44,7 @@
         public int lastMaxHeight;
 
         public ImageContainer get(String requestUrl, ImageListener imageListener, int maxWidth,
-                int maxHeight) {
+                int maxHeight, ScaleType scaleType) {
             lastRequestUrl = requestUrl;
             lastMaxWidth = maxWidth;
             lastMaxHeight = maxHeight;
diff --git a/src/test/java/com/android/volley/utils/CacheTestUtils.java b/src/test/java/com/android/volley/utils/CacheTestUtils.java
index cd2b8e7..898d055 100644
--- a/src/test/java/com/android/volley/utils/CacheTestUtils.java
+++ b/src/test/java/com/android/volley/utils/CacheTestUtils.java
@@ -24,7 +24,7 @@
             entry.data = new byte[random.nextInt(1024)];
         }
         entry.etag = String.valueOf(random.nextLong());
-        entry.serverDate = random.nextLong();
+        entry.lastModified = random.nextLong();
         entry.ttl = isExpired ? 0 : Long.MAX_VALUE;
         entry.softTtl = needsRefresh ? 0 : Long.MAX_VALUE;
         return entry;
diff --git a/src/test/resources/org.robolectric.Config.properties b/src/test/resources/org.robolectric.Config.properties
new file mode 100644
index 0000000..9daf692
--- /dev/null
+++ b/src/test/resources/org.robolectric.Config.properties
@@ -0,0 +1 @@
+manifest=src/main/AndroidManifest.xml