Log CT log list download failures

This CL adds a DownloadStatus class inside the DownloadHelper. The class
acts as a thin wrapper around the status and reason Ids returned by the
DownloadManager, with some utility methods to help with the
interpretation of the download status.

Bug: 319829948
Test: atest NetworkSecurityUnitTests
Change-Id: I111388ee37c09e30c02bb9bc9b3d65273961b91d
diff --git a/networksecurity/service/Android.bp b/networksecurity/service/Android.bp
index 52667ae..a41e6a0 100644
--- a/networksecurity/service/Android.bp
+++ b/networksecurity/service/Android.bp
@@ -32,6 +32,15 @@
         "service-connectivity-pre-jarjar",
     ],
 
+    static_libs: [
+        "auto_value_annotations",
+    ],
+
+    plugins: [
+        "auto_value_plugin",
+        "auto_annotation_plugin",
+    ],
+
     // This is included in service-connectivity which is 30+
     // TODO (b/293613362): allow APEXes to have service jars with higher min_sdk than the APEX
     // (service-connectivity is only used on 31+) and use 31 here
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
index 16f32c4..f86d127 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
@@ -29,6 +29,8 @@
 
 import androidx.annotation.VisibleForTesting;
 
+import com.android.server.net.ct.DownloadHelper.DownloadStatus;
+
 import java.io.IOException;
 import java.io.InputStream;
 import java.security.GeneralSecurityException;
@@ -147,19 +149,18 @@
     }
 
     private void handleMetadataDownloadCompleted(long downloadId) {
-        if (!mDownloadHelper.isSuccessful(downloadId)) {
-            Log.w(TAG, "Metadata download failed.");
-            // TODO: re-attempt download
+        DownloadStatus status = mDownloadHelper.getDownloadStatus(downloadId);
+        if (!status.isSuccessful()) {
+            handleDownloadFailed(status);
             return;
         }
-
         startContentDownload(mDataStore.getProperty(Config.CONTENT_URL_PENDING));
     }
 
     private void handleContentDownloadCompleted(long downloadId) {
-        if (!mDownloadHelper.isSuccessful(downloadId)) {
-            Log.w(TAG, "Content download failed.");
-            // TODO: re-attempt download
+        DownloadStatus status = mDownloadHelper.getDownloadStatus(downloadId);
+        if (!status.isSuccessful()) {
+            handleDownloadFailed(status);
             return;
         }
 
@@ -202,6 +203,11 @@
         }
     }
 
+    private void handleDownloadFailed(DownloadStatus status) {
+        Log.e(TAG, "Content download failed with " + status);
+        // TODO(378626065): Report failure via statsd.
+    }
+
     private boolean verify(Uri file, Uri signature) throws IOException, GeneralSecurityException {
         if (!mPublicKey.isPresent()) {
             throw new InvalidKeyException("Missing public key for signature verification");
diff --git a/networksecurity/service/src/com/android/server/net/ct/DownloadHelper.java b/networksecurity/service/src/com/android/server/net/ct/DownloadHelper.java
index cc8c4c0..5748416 100644
--- a/networksecurity/service/src/com/android/server/net/ct/DownloadHelper.java
+++ b/networksecurity/service/src/com/android/server/net/ct/DownloadHelper.java
@@ -24,6 +24,8 @@
 
 import androidx.annotation.VisibleForTesting;
 
+import com.google.auto.value.AutoValue;
+
 /** Class to handle downloads for Certificate Transparency. */
 public class DownloadHelper {
 
@@ -53,25 +55,22 @@
     }
 
     /**
-     * Returns true if the specified download completed successfully.
+     * Returns the status of the provided download id.
      *
      * @param downloadId the download.
-     * @return true if the download completed successfully.
+     * @return {@link DownloadStatus} of the download.
      */
-    public boolean isSuccessful(long downloadId) {
+    public DownloadStatus getDownloadStatus(long downloadId) {
+        DownloadStatus.Builder builder = DownloadStatus.builder().setDownloadId(downloadId);
         try (Cursor cursor = mDownloadManager.query(new Query().setFilterById(downloadId))) {
-            if (cursor == null) {
-                return false;
-            }
-            if (cursor.moveToFirst()) {
-                int status =
-                        cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
-                if (DownloadManager.STATUS_SUCCESSFUL == status) {
-                    return true;
-                }
+            if (cursor != null && cursor.moveToFirst()) {
+                builder.setStatus(
+                        cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)));
+                builder.setReason(
+                        cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_REASON)));
             }
         }
-        return false;
+        return builder.build();
     }
 
     /**
@@ -87,4 +86,58 @@
         }
         return mDownloadManager.getUriForDownloadedFile(downloadId);
     }
+
+    /** A wrapper around the status and reason Ids returned by the {@link DownloadManager}. */
+    @AutoValue
+    public abstract static class DownloadStatus {
+
+        abstract long downloadId();
+
+        abstract int status();
+
+        abstract int reason();
+
+        boolean isSuccessful() {
+            return status() == DownloadManager.STATUS_SUCCESSFUL;
+        }
+
+        boolean isStorageError() {
+            int status = status();
+            int reason = reason();
+            return status == DownloadManager.STATUS_FAILED
+                    && (reason == DownloadManager.ERROR_DEVICE_NOT_FOUND
+                            || reason == DownloadManager.ERROR_FILE_ERROR
+                            || reason == DownloadManager.ERROR_FILE_ALREADY_EXISTS
+                            || reason == DownloadManager.ERROR_INSUFFICIENT_SPACE);
+        }
+
+        boolean isHttpError() {
+            int status = status();
+            int reason = reason();
+            return status == DownloadManager.STATUS_FAILED
+                    && (reason == DownloadManager.ERROR_HTTP_DATA_ERROR
+                            || reason == DownloadManager.ERROR_TOO_MANY_REDIRECTS
+                            || reason == DownloadManager.ERROR_UNHANDLED_HTTP_CODE
+                            // If an HTTP error occurred, reason will hold the HTTP status code.
+                            || (400 <= reason && reason < 600));
+        }
+
+        @AutoValue.Builder
+        abstract static class Builder {
+            abstract Builder setDownloadId(long downloadId);
+
+            abstract Builder setStatus(int status);
+
+            abstract Builder setReason(int reason);
+
+            abstract DownloadStatus build();
+        }
+
+        static Builder builder() {
+            return new AutoValue_DownloadHelper_DownloadStatus.Builder()
+                    .setDownloadId(-1)
+                    .setStatus(-1)
+                    .setReason(-1);
+        }
+    }
 }
diff --git a/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
index df02446..fb55295 100644
--- a/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
+++ b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
@@ -31,6 +31,8 @@
 
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import com.android.server.net.ct.DownloadHelper.DownloadStatus;
+
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -115,11 +117,11 @@
     }
 
     @Test
-    public void testDownloader_handleMetadataCompleteSuccessful() {
+    public void testDownloader_metadataDownloadSuccess_startContentDownload() {
         long metadataId = 123;
         mDataStore.setPropertyLong(Config.METADATA_URL_KEY, metadataId);
-        when(mDownloadHelper.isSuccessful(metadataId)).thenReturn(true);
-
+        when(mDownloadHelper.getDownloadStatus(metadataId))
+                .thenReturn(makeSuccessfulDownloadStatus(metadataId));
         long contentId = 666;
         String contentUrl = "http://test-content.org";
         mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUrl);
@@ -132,23 +134,28 @@
     }
 
     @Test
-    public void testDownloader_handleMetadataCompleteFailed() {
+    public void testDownloader_metadataDownloadFail_doNotStartContentDownload() {
         long metadataId = 123;
         mDataStore.setPropertyLong(Config.METADATA_URL_KEY, metadataId);
-        when(mDownloadHelper.isSuccessful(metadataId)).thenReturn(false);
-
         String contentUrl = "http://test-content.org";
         mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUrl);
+        Intent downloadCompleteIntent = makeDownloadCompleteIntent(metadataId);
+        // In all these failure cases we give up on the download.
+        when(mDownloadHelper.getDownloadStatus(metadataId))
+                .thenReturn(
+                        makeHttpErrorDownloadStatus(metadataId),
+                        makeStorageErrorDownloadStatus(metadataId));
 
-        mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(metadataId));
+        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
 
         verify(mDownloadHelper, never()).startDownload(contentUrl);
     }
 
     @Test
-    public void testDownloader_handleContentCompleteInstallSuccessful() throws Exception {
-        String version = "666";
+    public void testDownloader_contentDownloadSuccess_installSuccess_updateDataStore()
+            throws Exception {
+        String version = "456";
         long contentId = 666;
         File logListFile = File.createTempFile("log_list", "json");
         Uri contentUri = Uri.fromFile(logListFile);
@@ -157,8 +164,8 @@
         Uri metadataUri = Uri.fromFile(metadataFile);
         mCertificateTransparencyDownloader.setPublicKey(
                 Base64.getEncoder().encodeToString(mPublicKey.getEncoded()));
-
-        setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
+        setUpContentDownloadCompleteSuccessful(
+                version, metadataId, metadataUri, contentId, contentUri);
         when(mCertificateTransparencyInstaller.install(
                         eq(Config.COMPATIBILITY_VERSION), any(), eq(version)))
                 .thenReturn(true);
@@ -166,7 +173,6 @@
         assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
         assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isNull();
         assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
-
         mCertificateTransparencyDownloader.onReceive(
                 mContext, makeDownloadCompleteIntent(contentId));
 
@@ -178,16 +184,38 @@
     }
 
     @Test
-    public void testDownloader_handleContentCompleteInstallFails() throws Exception {
-        String version = "666";
+    public void testDownloader_contentDownloadFail_doNotInstall() throws Exception {
+        mDataStore.setProperty(Config.VERSION_PENDING, "123");
+        long contentId = 666;
+        Intent downloadCompleteIntent = makeDownloadCompleteIntent(contentId);
+        // In all these failure cases we give up on the download.
+        when(mDownloadHelper.getDownloadStatus(contentId))
+                .thenReturn(
+                        makeHttpErrorDownloadStatus(contentId),
+                        makeStorageErrorDownloadStatus(contentId));
+
+        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+
+        verify(mCertificateTransparencyInstaller, never()).install(any(), any(), any());
+        assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
+        assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isNull();
+        assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
+    }
+
+    @Test
+    public void testDownloader_contentDownloadSuccess_installFail_doNotUpdateDataStore()
+            throws Exception {
+        String version = "456";
         long contentId = 666;
         File logListFile = File.createTempFile("log_list", "json");
         Uri contentUri = Uri.fromFile(logListFile);
         long metadataId = 123;
         File metadataFile = sign(logListFile);
         Uri metadataUri = Uri.fromFile(metadataFile);
-
-        setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
+        setUpContentDownloadCompleteSuccessful(
+                version, metadataId, metadataUri, contentId, contentUri);
         when(mCertificateTransparencyInstaller.install(
                         eq(Config.COMPATIBILITY_VERSION), any(), eq(version)))
                 .thenReturn(false);
@@ -201,14 +229,15 @@
     }
 
     @Test
-    public void testDownloader_handleContentCompleteVerificationFails() throws IOException {
-        String version = "666";
+    public void testDownloader_contentDownloadSuccess_verificationFail_doNotInstall()
+            throws IOException {
+        String version = "456";
         long contentId = 666;
         Uri contentUri = Uri.fromFile(File.createTempFile("log_list", "json"));
         long metadataId = 123;
         Uri metadataUri = Uri.fromFile(File.createTempFile("log_list-wrong_metadata", "sig"));
-
-        setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
+        setUpContentDownloadCompleteSuccessful(
+                version, metadataId, metadataUri, contentId, contentUri);
 
         mCertificateTransparencyDownloader.onReceive(
                 mContext, makeDownloadCompleteIntent(contentId));
@@ -221,17 +250,17 @@
     }
 
     @Test
-    public void testDownloader_handleContentCompleteMissingVerificationPublicKey()
+    public void testDownloader_contentDownloadSuccess_missingVerificationPublicKey_doNotInstall()
             throws Exception {
-        String version = "666";
+        String version = "456";
         long contentId = 666;
         File logListFile = File.createTempFile("log_list", "json");
         Uri contentUri = Uri.fromFile(logListFile);
         long metadataId = 123;
         File metadataFile = sign(logListFile);
         Uri metadataUri = Uri.fromFile(metadataFile);
-
-        setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
+        setUpContentDownloadCompleteSuccessful(
+                version, metadataId, metadataUri, contentId, contentUri);
 
         mCertificateTransparencyDownloader.onReceive(
                 mContext, makeDownloadCompleteIntent(contentId));
@@ -248,7 +277,7 @@
                 .putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, downloadId);
     }
 
-    private void setUpDownloadComplete(
+    private void setUpContentDownloadCompleteSuccessful(
             String version, long metadataId, Uri metadataUri, long contentId, Uri contentUri)
             throws IOException {
         mDataStore.setProperty(Config.VERSION_PENDING, version);
@@ -259,10 +288,34 @@
 
         mDataStore.setPropertyLong(Config.CONTENT_URL_KEY, contentId);
         mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUri.toString());
-        when(mDownloadHelper.isSuccessful(contentId)).thenReturn(true);
+        when(mDownloadHelper.getDownloadStatus(contentId))
+                .thenReturn(makeSuccessfulDownloadStatus(contentId));
         when(mDownloadHelper.getUri(contentId)).thenReturn(contentUri);
     }
 
+    private DownloadStatus makeSuccessfulDownloadStatus(long downloadId) {
+        return DownloadStatus.builder()
+                .setDownloadId(downloadId)
+                .setStatus(DownloadManager.STATUS_SUCCESSFUL)
+                .build();
+    }
+
+    private DownloadStatus makeStorageErrorDownloadStatus(long downloadId) {
+        return DownloadStatus.builder()
+                .setDownloadId(downloadId)
+                .setStatus(DownloadManager.STATUS_FAILED)
+                .setReason(DownloadManager.ERROR_INSUFFICIENT_SPACE)
+                .build();
+    }
+
+    private DownloadStatus makeHttpErrorDownloadStatus(long downloadId) {
+        return DownloadStatus.builder()
+                .setDownloadId(downloadId)
+                .setStatus(DownloadManager.STATUS_FAILED)
+                .setReason(DownloadManager.ERROR_HTTP_DATA_ERROR)
+                .build();
+    }
+
     private File sign(File file) throws IOException, GeneralSecurityException {
         File signatureFile = File.createTempFile("log_list-metadata", "sig");
         Signature signer = Signature.getInstance("SHA256withRSA");