Implement the composer picture manager in Telephony

Flesh out the implementation of CallComposerPictureManager and add unit
tests for its functionality. Integration into the call flow is pending.

Test: atest PictureManagerTest
Bug: 175435766
Change-Id: I058c4a287224443089ad6f071203e5a56540cfbc
(cherry picked from commit e32fd6043d514a53691ca676e0adcfb44035173a)
diff --git a/res/drawable/cupcake.png b/res/drawable/cupcake.png
new file mode 100644
index 0000000..dcc74e5
--- /dev/null
+++ b/res/drawable/cupcake.png
Binary files differ
diff --git a/src/com/android/phone/callcomposer/CallComposerPictureManager.java b/src/com/android/phone/callcomposer/CallComposerPictureManager.java
index 7ffeeef..81f088a 100644
--- a/src/com/android/phone/callcomposer/CallComposerPictureManager.java
+++ b/src/com/android/phone/callcomposer/CallComposerPictureManager.java
@@ -18,18 +18,46 @@
 
 import android.content.Context;
 import android.net.Uri;
+import android.os.OutcomeReceiver;
+import android.os.UserHandle;
+import android.provider.CallLog;
+import android.telephony.CarrierConfigManager;
+import android.telephony.TelephonyManager;
+import android.telephony.gba.UaSecurityProtocolIdentifier;
+import android.text.TextUtils;
+import android.util.Log;
 import android.util.Pair;
 import android.util.SparseArray;
 
+import androidx.annotation.NonNull;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.phone.callcomposer.CallComposerPictureTransfer.PictureCallback;
+import com.android.phone.R;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
 import java.util.HashMap;
+import java.util.Map;
 import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.Consumer;
 
 public class CallComposerPictureManager {
+    private static final String TAG = CallComposerPictureManager.class.getSimpleName();
     private static final SparseArray<CallComposerPictureManager> sInstances = new SparseArray<>();
 
     public static CallComposerPictureManager getInstance(Context context, int subscriptionId) {
         synchronized (sInstances) {
+            if (sExecutorService == null) {
+                sExecutorService = Executors.newSingleThreadScheduledExecutor();
+            }
             if (!sInstances.contains(subscriptionId)) {
                 sInstances.put(subscriptionId,
                         new CallComposerPictureManager(context, subscriptionId));
@@ -38,22 +66,277 @@
         }
     }
 
-    private HashMap<UUID, ImageData> mCachedPics = new HashMap<>();
-    private HashMap<UUID, String> mCachedServerUrls = new HashMap<>();
-    private GbaCredentials mCachedCredentials;
+    @VisibleForTesting
+    public static void clearInstances() {
+        synchronized (sInstances) {
+            sInstances.clear();
+            if (sExecutorService != null) {
+                sExecutorService.shutdown();
+                sExecutorService = null;
+            }
+        }
+    }
+
+    // disabled provisionally until the auth stack is fully operational
+    @VisibleForTesting
+    public static boolean sHttpOperationsEnabled = false;
+    private static final String FAKE_SERVER_URL = "https://example.com/FAKE.png";
+
+    public interface CallLogProxy {
+        default void storeCallComposerPictureAsUser(Context context,
+                UserHandle user,
+                InputStream input,
+                Executor executor,
+                OutcomeReceiver<Uri, CallLog.CallComposerLoggingException> callback) {
+            CallLog.storeCallComposerPictureAsUser(context, user, input, executor, callback);
+        }
+    }
+
+    private static ScheduledExecutorService sExecutorService = null;
+
+    private final HashMap<UUID, String> mCachedServerUrls = new HashMap<>();
+    private final HashMap<UUID, ImageData> mCachedImages = new HashMap<>();
+    private final Map<String, GbaCredentials> mCachedCredentials = new HashMap<>();
     private final int mSubscriptionId;
+    private final TelephonyManager mTelephonyManager;
     private final Context mContext;
+    private CallLogProxy mCallLogProxy = new CallLogProxy() {};
 
     private CallComposerPictureManager(Context context, int subscriptionId) {
         mContext = context;
         mSubscriptionId = subscriptionId;
+        mTelephonyManager = mContext.getSystemService(TelephonyManager.class)
+                .createForSubscriptionId(mSubscriptionId);
     }
 
-    public void handleUploadToServer(ImageData imageData, Consumer<Pair<UUID, Integer>> callback) {
-        // TODO: plumbing
+    public void handleUploadToServer(CallComposerPictureTransfer.Factory transferFactory,
+            ImageData imageData, Consumer<Pair<UUID, Integer>> callback) {
+        if (!sHttpOperationsEnabled) {
+            UUID id = UUID.randomUUID();
+            mCachedImages.put(id, imageData);
+            mCachedServerUrls.put(id, FAKE_SERVER_URL);
+            callback.accept(Pair.create(id, TelephonyManager.CallComposerException.SUCCESS));
+            return;
+        }
+
+        String uploadUrl = mTelephonyManager.getCarrierConfig().getString(
+                CarrierConfigManager.KEY_CALL_COMPOSER_PICTURE_SERVER_URL_STRING);
+        if (TextUtils.isEmpty(uploadUrl)) {
+            Log.e(TAG, "Call composer upload URL not configured in carrier config");
+            callback.accept(Pair.create(null,
+                    TelephonyManager.CallComposerException.ERROR_UNKNOWN));
+        }
+        UUID id = UUID.randomUUID();
+        imageData.setId(id.toString());
+
+        CallComposerPictureTransfer transfer = transferFactory.create(mContext,
+                mSubscriptionId, uploadUrl, sExecutorService);
+
+        AtomicBoolean hasRetried = new AtomicBoolean(false);
+        transfer.setCallback(new PictureCallback() {
+            @Override
+            public void onError(int error) {
+                callback.accept(Pair.create(null, error));
+            }
+
+            @Override
+            public void onRetryNeeded(boolean credentialRefresh, long backoffMillis) {
+                if (hasRetried.getAndSet(true)) {
+                    Log.e(TAG, "Giving up on image upload after one retry.");
+                    callback.accept(Pair.create(null,
+                            TelephonyManager.CallComposerException.ERROR_NETWORK_UNAVAILABLE));
+                    return;
+                }
+                GbaCredentialsSupplier supplier =
+                        (realm, executor) ->
+                                getGbaCredentials(credentialRefresh, realm, executor);
+
+                sExecutorService.schedule(() -> transfer.uploadPicture(imageData, supplier),
+                        backoffMillis, TimeUnit.MILLISECONDS);
+            }
+
+            @Override
+            public void onUploadSuccessful(String serverUrl) {
+                mCachedServerUrls.put(id, serverUrl);
+                mCachedImages.put(id, imageData);
+                Log.i(TAG, "Successfully received url: " + serverUrl + " associated with "
+                        + id.toString());
+                callback.accept(Pair.create(id, TelephonyManager.CallComposerException.SUCCESS));
+            }
+        });
+
+        transfer.uploadPicture(imageData,
+                (realm, executor) -> getGbaCredentials(false, realm, executor));
     }
 
-    public void handleDownloadFromServer(String remoteUrl, Consumer<Pair<Uri, Integer>> callback) {
-        // TODO: plumbing, insert to call log
+    public void handleDownloadFromServer(CallComposerPictureTransfer.Factory transferFactory,
+            String remoteUrl, Consumer<Pair<Uri, Integer>> callback) {
+        if (!sHttpOperationsEnabled) {
+            ImageData imageData = new ImageData(getPlaceholderPictureAsBytes(), "image/png", null);
+            UUID id = UUID.randomUUID();
+            mCachedImages.put(id, imageData);
+            storeUploadedPictureToCallLog(id, uri -> callback.accept(Pair.create(uri, -1)));
+            return;
+        }
+
+        CallComposerPictureTransfer transfer = transferFactory.create(mContext,
+                mSubscriptionId, remoteUrl, sExecutorService);
+
+        AtomicBoolean hasRetried = new AtomicBoolean(false);
+        transfer.setCallback(new PictureCallback() {
+            @Override
+            public void onError(int error) {
+                callback.accept(Pair.create(null, error));
+            }
+
+            @Override
+            public void onRetryNeeded(boolean credentialRefresh, long backoffMillis) {
+                if (hasRetried.getAndSet(true)) {
+                    Log.e(TAG, "Giving up on image download after one retry.");
+                    callback.accept(Pair.create(null,
+                            TelephonyManager.CallComposerException.ERROR_NETWORK_UNAVAILABLE));
+                    return;
+                }
+                GbaCredentialsSupplier supplier =
+                        (realm, executor) ->
+                                getGbaCredentials(credentialRefresh, realm, executor);
+
+                sExecutorService.schedule(() -> transfer.downloadPicture(supplier),
+                        backoffMillis, TimeUnit.MILLISECONDS);
+            }
+
+            @Override
+            public void onDownloadSuccessful(ImageData data) {
+                ByteArrayInputStream imageDataInput =
+                        new ByteArrayInputStream(data.getImageBytes());
+                mCallLogProxy.storeCallComposerPictureAsUser(
+                        mContext, UserHandle.CURRENT, imageDataInput,
+                        sExecutorService,
+                        new OutcomeReceiver<Uri, CallLog.CallComposerLoggingException>() {
+                            @Override
+                            public void onResult(@NonNull Uri result) {
+                                callback.accept(Pair.create(
+                                        result, TelephonyManager.CallComposerException.SUCCESS));
+                            }
+
+                            @Override
+                            public void onError(CallLog.CallComposerLoggingException e) {
+                                // Just report an error to the client for now.
+                                callback.accept(Pair.create(null,
+                                        TelephonyManager.CallComposerException.ERROR_UNKNOWN));
+                            }
+                        });
+            }
+        });
+
+        transfer.downloadPicture(((realm, executor) -> getGbaCredentials(false, realm, executor)));
+    }
+
+    public void storeUploadedPictureToCallLog(UUID id, Consumer<Uri> callback) {
+        ImageData data = mCachedImages.get(id);
+        if (data == null) {
+            Log.e(TAG, "No picture associated with uuid " + id);
+            callback.accept(null);
+            return;
+        }
+        ByteArrayInputStream imageDataInput =
+                new ByteArrayInputStream(data.getImageBytes());
+        mCallLogProxy.storeCallComposerPictureAsUser(mContext, UserHandle.CURRENT, imageDataInput,
+                sExecutorService,
+                new OutcomeReceiver<Uri, CallLog.CallComposerLoggingException>() {
+                    @Override
+                    public void onResult(@NonNull Uri result) {
+                        callback.accept(result);
+                        clearCachedData();
+                    }
+
+                    @Override
+                    public void onError(CallLog.CallComposerLoggingException e) {
+                        // Just report an error to the client for now.
+                        Log.e(TAG, "Error logging uploaded image: " + e.getErrorCode());
+                        callback.accept(null);
+                        clearCachedData();
+                    }
+                });
+    }
+
+    public String getServerUrlForImageId(UUID id) {
+        return mCachedServerUrls.get(id);
+    }
+
+    public void clearCachedData() {
+        mCachedServerUrls.clear();
+        mCachedImages.clear();
+    }
+
+    private byte[] getPlaceholderPictureAsBytes() {
+        InputStream resourceInput = mContext.getResources().openRawResource(R.drawable.cupcake);
+        try {
+            return readBytes(resourceInput);
+        } catch (Exception e) {
+            return new byte[] {};
+        }
+    }
+
+    private static byte[] readBytes(InputStream inputStream) throws Exception {
+        byte[] buffer = new byte[1024];
+        ByteArrayOutputStream output = new ByteArrayOutputStream();
+        int numRead;
+        do {
+            numRead = inputStream.read(buffer);
+            if (numRead > 0) output.write(buffer, 0, numRead);
+        } while (numRead > 0);
+        return output.toByteArray();
+    }
+
+    private CompletableFuture<GbaCredentials> getGbaCredentials(
+            boolean forceRefresh, String nafId, Executor executor) {
+        synchronized (mCachedCredentials) {
+            if (!forceRefresh && mCachedCredentials.containsKey(nafId)) {
+                return CompletableFuture.completedFuture(mCachedCredentials.get(nafId));
+            }
+            if (forceRefresh) {
+                mCachedCredentials.remove(nafId);
+            }
+        }
+
+        UaSecurityProtocolIdentifier securityProtocolIdentifier =
+                new UaSecurityProtocolIdentifier.Builder()
+                        .setOrg(UaSecurityProtocolIdentifier.ORG_3GPP)
+                        .setProtocol(UaSecurityProtocolIdentifier
+                                .UA_SECURITY_PROTOCOL_3GPP_HTTP_DIGEST_AUTHENTICATION)
+                        .build();
+        CompletableFuture<GbaCredentials> resultFuture = new CompletableFuture<>();
+
+        mTelephonyManager.bootstrapAuthenticationRequest(TelephonyManager.APPTYPE_UNKNOWN,
+                Uri.parse(nafId), securityProtocolIdentifier, forceRefresh, executor,
+                new TelephonyManager.BootstrapAuthenticationCallback() {
+                    @Override
+                    public void onKeysAvailable(byte[] gbaKey, String transactionId) {
+                        GbaCredentials creds = new GbaCredentials(transactionId, gbaKey);
+                        synchronized (mCachedCredentials) {
+                            mCachedCredentials.put(nafId, creds);
+                        }
+                        resultFuture.complete(creds);
+                    }
+
+                    @Override
+                    public void onAuthenticationFailure(int reason) {
+                        Log.e(TAG, "GBA auth failed: reason=" + reason);
+                        resultFuture.complete(null);
+                    }
+                });
+
+        return resultFuture;
+    }
+
+    @VisibleForTesting
+    static ScheduledExecutorService getExecutor() {
+        return sExecutorService;
+    }
+
+    @VisibleForTesting
+    void setCallLogProxy(CallLogProxy proxy) {
+        mCallLogProxy = proxy;
     }
 }
diff --git a/src/com/android/phone/callcomposer/CallComposerPictureTransfer.java b/src/com/android/phone/callcomposer/CallComposerPictureTransfer.java
index d56b7f1..1a176dd 100644
--- a/src/com/android/phone/callcomposer/CallComposerPictureTransfer.java
+++ b/src/com/android/phone/callcomposer/CallComposerPictureTransfer.java
@@ -26,11 +26,14 @@
 
 import androidx.annotation.NonNull;
 
+import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.http.multipart.MultipartEntity;
 import com.android.internal.http.multipart.Part;
 
 import com.google.common.net.MediaType;
 
+import gov.nist.javax.sip.header.WWWAuthenticate;
+
 import org.xml.sax.InputSource;
 
 import java.io.BufferedReader;
@@ -49,7 +52,6 @@
 import java.util.Iterator;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
 
 import javax.xml.namespace.NamespaceContext;
 import javax.xml.xpath.XPath;
@@ -68,6 +70,13 @@
     private static final int ERROR_NO_AUTH_REQUIRED = 2;
     private static final int ERROR_FORBIDDEN = 3;
 
+    public interface Factory {
+        default CallComposerPictureTransfer create(Context context, int subscriptionId, String url,
+                ExecutorService executorService) {
+            return new CallComposerPictureTransfer(context, subscriptionId, url, executorService);
+        }
+    }
+
     public interface PictureCallback {
         default void onError(@TelephonyManager.CallComposerException.CallComposerError int error) {}
         default void onRetryNeeded(boolean credentialRefresh, long backoffMillis) {}
@@ -86,30 +95,45 @@
     private final Context mContext;
     private final int mSubscriptionId;
     private final String mUrl;
-    private final ExecutorService mExecutorService = Executors.newSingleThreadExecutor();
+    private final ExecutorService mExecutorService;
 
-    // Only one of these is nonnull per instance.
     private PictureCallback mCallback;
 
-    public CallComposerPictureTransfer(Context context, int subscriptionId, String url,
-            PictureCallback downloadCallback) {
+    private CallComposerPictureTransfer(Context context, int subscriptionId, String url,
+            ExecutorService executorService) {
         mContext = context;
         mSubscriptionId = subscriptionId;
+        mExecutorService = executorService;
         mUrl = url;
-        mCallback = downloadCallback;
     }
 
-    public void uploadPicture(ImageData image, GbaCredentials credentials) {
+    @VisibleForTesting
+    public void setCallback(PictureCallback callback) {
+        mCallback = callback;
+    }
+
+    public void uploadPicture(ImageData image,
+            GbaCredentialsSupplier credentialsSupplier) {
         CompletableFuture<Network> networkFuture = getNetworkForCallComposer();
-        CompletableFuture<String> authorizationFuture = networkFuture
+        CompletableFuture<WWWAuthenticate> authorizationHeaderFuture = networkFuture
                 .thenApplyAsync((network) -> prepareInitialPost(network, mUrl), mExecutorService)
                 .thenComposeAsync(this::obtainAuthenticateHeader, mExecutorService)
-                .thenApplyAsync((authHeader) ->
-                        DigestAuthUtils.generateAuthorizationHeader(
-                                authHeader, credentials, "POST", mUrl), mExecutorService)
-                .whenCompleteAsync((authorization, error) -> handleExceptionalCompletion(error),
+                .thenApplyAsync(DigestAuthUtils::parseAuthenticateHeader);
+        CompletableFuture<GbaCredentials> credsFuture = authorizationHeaderFuture
+                .thenComposeAsync((header) ->
+                        credentialsSupplier.getCredentials(header.getRealm(), mExecutorService),
                         mExecutorService);
 
+        CompletableFuture<String> authorizationFuture =
+                authorizationHeaderFuture.thenCombineAsync(credsFuture,
+                        (authHeader, credentials) ->
+                                DigestAuthUtils.generateAuthorizationHeader(
+                                        authHeader, credentials, "POST", mUrl),
+                        mExecutorService)
+                        .whenCompleteAsync(
+                                (authorization, error) -> handleExceptionalCompletion(error),
+                                mExecutorService);
+
         CompletableFuture<String> networkUrlFuture =
                 networkFuture.thenCombineAsync(authorizationFuture,
                         (network, auth) -> sendActualImageUpload(network, auth, image),
@@ -119,7 +143,7 @@
         }, mExecutorService);
     }
 
-    public void downloadPicture(GbaCredentials credentials) {
+    public void downloadPicture(GbaCredentialsSupplier credentialsSupplier) {
         CompletableFuture<Network> networkFuture = getNetworkForCallComposer();
         CompletableFuture<HttpURLConnection> getConnectionFuture =
                 networkFuture.thenApplyAsync((network) ->
@@ -149,12 +173,22 @@
                         logException("IOException obtaining return code: ", e);
                         throw new NetworkAccessException(ERROR_HTTP_TIMEOUT);
                     }
-                    CompletableFuture<String> authorizationFuture = obtainAuthenticateHeader(conn)
-                            .thenApplyAsync((authHeader) ->
+                    CompletableFuture<WWWAuthenticate> authenticateHeaderFuture =
+                            obtainAuthenticateHeader(conn)
+                                    .thenApply(DigestAuthUtils::parseAuthenticateHeader);
+                    CompletableFuture<GbaCredentials> credsFuture = authenticateHeaderFuture
+                            .thenComposeAsync((header) ->
+                                    credentialsSupplier.getCredentials(header.getRealm(),
+                                            mExecutorService), mExecutorService);
+
+                    CompletableFuture<String> authorizationFuture = authenticateHeaderFuture
+                            .thenCombineAsync(credsFuture, (authHeader, credentials) ->
                                     DigestAuthUtils.generateAuthorizationHeader(
-                                            authHeader, credentials, "GET", mUrl), mExecutorService)
+                                            authHeader, credentials, "GET", mUrl),
+                                    mExecutorService)
                             .whenCompleteAsync((authorization, error) ->
                                     handleExceptionalCompletion(error), mExecutorService);
+
                     return networkFuture.thenCombineAsync(authorizationFuture,
                             this::downloadImageWithAuth, mExecutorService);
                 }, mExecutorService);
@@ -172,10 +206,6 @@
         });
     }
 
-    public void shutdown() {
-        mExecutorService.shutdown();
-    }
-
     private CompletableFuture<Network> getNetworkForCallComposer() {
         ConnectivityManager connectivityManager =
                 mContext.getSystemService(ConnectivityManager.class);
diff --git a/src/com/android/phone/callcomposer/DigestAuthUtils.java b/src/com/android/phone/callcomposer/DigestAuthUtils.java
index a9f589c..52a278b 100644
--- a/src/com/android/phone/callcomposer/DigestAuthUtils.java
+++ b/src/com/android/phone/callcomposer/DigestAuthUtils.java
@@ -39,18 +39,20 @@
     private static final int CNONCE_LENGTH_BYTES = 16;
     private static final String AUTH_QOP = "auth";
 
-    // Generates the Authorization header for use in future requests to the call composer server.
-    public static String generateAuthorizationHeader(String authHeader,
-            GbaCredentials credentials, String method, String uri) {
-        String reconstitutedHeader = WWW_AUTHENTICATE + ": " + authHeader;
+    public static WWWAuthenticate parseAuthenticateHeader(String header) {
+        String reconstitutedHeader = WWW_AUTHENTICATE + ": " + header;
         WWWAuthenticate parsedHeader;
         try {
-            parsedHeader =
-                    (WWWAuthenticate) (new WWWAuthenticateParser(reconstitutedHeader).parse());
+            return (WWWAuthenticate) (new WWWAuthenticateParser(reconstitutedHeader).parse());
         } catch (ParseException e) {
             Log.e(TAG, "Error parsing received auth header: " + e);
             return null;
         }
+    }
+
+    // Generates the Authorization header for use in future requests to the call composer server.
+    public static String generateAuthorizationHeader(WWWAuthenticate parsedHeader,
+            GbaCredentials credentials, String method, String uri) {
         if (!TextUtils.isEmpty(parsedHeader.getAlgorithm())
                 && !MD5_ALGORITHM.equals(parsedHeader.getAlgorithm().toLowerCase())) {
             Log.e(TAG, "This client only supports MD5 auth");
diff --git a/src/com/android/phone/callcomposer/GbaCredentialsSupplier.java b/src/com/android/phone/callcomposer/GbaCredentialsSupplier.java
new file mode 100644
index 0000000..9e5bb6a
--- /dev/null
+++ b/src/com/android/phone/callcomposer/GbaCredentialsSupplier.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2021 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.phone.callcomposer;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+
+public interface GbaCredentialsSupplier {
+    CompletableFuture<GbaCredentials> getCredentials(String realm, Executor executor);
+}
diff --git a/src/com/android/phone/callcomposer/ImageData.java b/src/com/android/phone/callcomposer/ImageData.java
index 77a61c0..fc93485 100644
--- a/src/com/android/phone/callcomposer/ImageData.java
+++ b/src/com/android/phone/callcomposer/ImageData.java
@@ -19,6 +19,7 @@
 public class ImageData {
     private final byte[] mImageBytes;
     private final String mMimeType;
+
     private String mId;
 
     public ImageData(byte[] imageBytes, String mimeType, String id) {
@@ -38,4 +39,8 @@
     public String getId() {
         return mId;
     }
+
+    public void setId(String id) {
+        mId = id;
+    }
 }
diff --git a/tests/src/com/android/phone/CallComposerAuthTest.java b/tests/src/com/android/phone/callcomposer/CallComposerAuthTest.java
similarity index 97%
rename from tests/src/com/android/phone/CallComposerAuthTest.java
rename to tests/src/com/android/phone/callcomposer/CallComposerAuthTest.java
index 9e253a0..b503790 100644
--- a/tests/src/com/android/phone/CallComposerAuthTest.java
+++ b/tests/src/com/android/phone/callcomposer/CallComposerAuthTest.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package com.android.phone;
+package com.android.phone.callcomposer;
 
 import static org.junit.Assert.assertEquals;
 
diff --git a/tests/src/com/android/phone/callcomposer/PictureManagerTest.java b/tests/src/com/android/phone/callcomposer/PictureManagerTest.java
new file mode 100644
index 0000000..80b1dd6
--- /dev/null
+++ b/tests/src/com/android/phone/callcomposer/PictureManagerTest.java
@@ -0,0 +1,312 @@
+/*
+ * Copyright (C) 2021 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.phone.callcomposer;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.nullable;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.OutcomeReceiver;
+import android.os.PersistableBundle;
+import android.os.UserHandle;
+import android.provider.CallLog;
+import android.telephony.CarrierConfigManager;
+import android.telephony.TelephonyManager;
+import android.telephony.gba.UaSecurityProtocolIdentifier;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.InputStream;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+public class PictureManagerTest {
+    private static final String FAKE_URL_BASE = "https://www.example.com";
+    private static final String FAKE_URL = "https://www.example.com/AAAAA";
+    private static final long TIMEOUT_MILLIS = 1000;
+    private static final Uri FAKE_CALLLOG_URI = Uri.parse("content://asdf");
+
+    @Mock CallComposerPictureManager.CallLogProxy mockCallLogProxy;
+    @Mock CallComposerPictureTransfer mockPictureTransfer;
+    @Mock Context context;
+    @Mock TelephonyManager telephonyManager;
+
+    private boolean originalHttpOpValue = false;
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+        originalHttpOpValue = CallComposerPictureManager.sHttpOperationsEnabled;
+        CallComposerPictureManager.sHttpOperationsEnabled = true;
+        when(context.getSystemService(Context.TELEPHONY_SERVICE)).thenReturn(telephonyManager);
+        when(context.getSystemServiceName(TelephonyManager.class))
+                .thenReturn(Context.TELEPHONY_SERVICE);
+        when(telephonyManager.createForSubscriptionId(anyInt())).thenReturn(telephonyManager);
+        PersistableBundle b = new PersistableBundle();
+        b.putString(CarrierConfigManager.KEY_CALL_COMPOSER_PICTURE_SERVER_URL_STRING,
+                FAKE_URL_BASE);
+        when(telephonyManager.getCarrierConfig()).thenReturn(b);
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        CallComposerPictureManager.sHttpOperationsEnabled = originalHttpOpValue;
+        CallComposerPictureManager.clearInstances();
+    }
+
+    @Test
+    public void testPictureUpload() throws Exception {
+        CallComposerPictureManager manager = CallComposerPictureManager.getInstance(context, 0);
+        manager.setCallLogProxy(mockCallLogProxy);
+        ImageData imageData = new ImageData(new byte[] {1,2,3,4},
+                "image/png", null);
+
+        CompletableFuture<UUID> uploadedUuidFuture = new CompletableFuture<>();
+        manager.handleUploadToServer(new CallComposerPictureTransfer.Factory() {
+            @Override
+            public CallComposerPictureTransfer create(Context context, int subscriptionId,
+                    String url, ExecutorService executorService) {
+                return mockPictureTransfer;
+            }
+        }, imageData, (pair) -> uploadedUuidFuture.complete(pair.first));
+
+        // Get the callback for later manipulation
+        ArgumentCaptor<CallComposerPictureTransfer.PictureCallback> callbackCaptor =
+                ArgumentCaptor.forClass(CallComposerPictureTransfer.PictureCallback.class);
+        verify(mockPictureTransfer).setCallback(callbackCaptor.capture());
+
+        // Make sure the upload method is called
+        ArgumentCaptor<GbaCredentialsSupplier> credSupplierCaptor =
+                ArgumentCaptor.forClass(GbaCredentialsSupplier.class);
+        ArgumentCaptor<ImageData> imageDataCaptor =
+                ArgumentCaptor.forClass(ImageData.class);
+        verify(mockPictureTransfer).uploadPicture(imageDataCaptor.capture(),
+                credSupplierCaptor.capture());
+
+        // Make sure the id field on the image data got filled in
+        ImageData sentData = imageDataCaptor.getValue();
+        assertArrayEquals(imageData.getImageBytes(), sentData.getImageBytes());
+        assertNotNull(sentData.getId());
+        String imageId = sentData.getId();
+
+        testGbaCredLookup(credSupplierCaptor.getValue(), false);
+
+        // Trigger upload success, make sure that the internal state is consistent after the upload.
+        callbackCaptor.getValue().onUploadSuccessful(FAKE_URL);
+        UUID id = uploadedUuidFuture.get(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+        assertEquals(imageId, id.toString());
+        assertEquals(FAKE_URL, manager.getServerUrlForImageId(id));
+
+        // Test the call log upload
+        CompletableFuture<Uri> callLogUriFuture = new CompletableFuture<>();
+        manager.storeUploadedPictureToCallLog(id, callLogUriFuture::complete);
+
+        ArgumentCaptor<OutcomeReceiver<Uri, CallLog.CallComposerLoggingException>>
+                callLogCallbackCaptor = ArgumentCaptor.forClass(OutcomeReceiver.class);
+
+        verify(mockCallLogProxy).storeCallComposerPictureAsUser(nullable(Context.class),
+                nullable(UserHandle.class), nullable(InputStream.class), nullable(Executor.class),
+                callLogCallbackCaptor.capture());
+        callLogCallbackCaptor.getValue().onResult(FAKE_CALLLOG_URI);
+        Uri receivedUri = callLogUriFuture.get(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+        assertEquals(FAKE_CALLLOG_URI, receivedUri);
+    }
+
+    @Test
+    public void testPictureUploadWithAuthRefresh() throws Exception {
+        CallComposerPictureManager manager = CallComposerPictureManager.getInstance(context, 0);
+        manager.setCallLogProxy(mockCallLogProxy);
+        ImageData imageData = new ImageData(new byte[] {1,2,3,4},
+                "image/png", null);
+
+        CompletableFuture<UUID> uploadedUuidFuture = new CompletableFuture<>();
+        manager.handleUploadToServer(new CallComposerPictureTransfer.Factory() {
+            @Override
+            public CallComposerPictureTransfer create(Context context, int subscriptionId,
+                    String url, ExecutorService executorService) {
+                return mockPictureTransfer;
+            }
+        }, imageData, (pair) -> uploadedUuidFuture.complete(pair.first));
+
+        // Get the callback for later manipulation
+        ArgumentCaptor<CallComposerPictureTransfer.PictureCallback> callbackCaptor =
+                ArgumentCaptor.forClass(CallComposerPictureTransfer.PictureCallback.class);
+        verify(mockPictureTransfer).setCallback(callbackCaptor.capture());
+
+        // Make sure the upload method is called
+        verify(mockPictureTransfer).uploadPicture(nullable(ImageData.class),
+                nullable(GbaCredentialsSupplier.class));
+
+        // Simulate a auth-needed retry request
+        callbackCaptor.getValue().onRetryNeeded(true, 0);
+        waitForExecutorAction(CallComposerPictureManager.getExecutor(), TIMEOUT_MILLIS);
+
+        // Make sure upload gets called again immediately, and make sure that the new GBA creds
+        // are requested with a force-refresh.
+        ArgumentCaptor<GbaCredentialsSupplier> credSupplierCaptor =
+                ArgumentCaptor.forClass(GbaCredentialsSupplier.class);
+        verify(mockPictureTransfer, times(2)).uploadPicture(nullable(ImageData.class),
+                credSupplierCaptor.capture());
+
+        testGbaCredLookup(credSupplierCaptor.getValue(), true);
+    }
+
+    @Test
+    public void testPictureDownload() throws Exception {
+        ImageData imageData = new ImageData(new byte[] {1,2,3,4},
+                "image/png", null);
+        CallComposerPictureManager manager = CallComposerPictureManager.getInstance(context, 0);
+        manager.setCallLogProxy(mockCallLogProxy);
+
+        CompletableFuture<Uri> callLogUriFuture = new CompletableFuture<>();
+        manager.handleDownloadFromServer(new CallComposerPictureTransfer.Factory() {
+            @Override
+            public CallComposerPictureTransfer create(Context context, int subscriptionId,
+                    String url, ExecutorService executorService) {
+                return mockPictureTransfer;
+            }
+        }, FAKE_URL, (p) -> callLogUriFuture.complete(p.first));
+
+        // Get the callback for later manipulation
+        ArgumentCaptor<CallComposerPictureTransfer.PictureCallback> callbackCaptor =
+                ArgumentCaptor.forClass(CallComposerPictureTransfer.PictureCallback.class);
+        verify(mockPictureTransfer).setCallback(callbackCaptor.capture());
+
+        // Make sure the download method is called
+        ArgumentCaptor<GbaCredentialsSupplier> credSupplierCaptor =
+                ArgumentCaptor.forClass(GbaCredentialsSupplier.class);
+        verify(mockPictureTransfer).downloadPicture(credSupplierCaptor.capture());
+
+        testGbaCredLookup(credSupplierCaptor.getValue(), false);
+
+        // Trigger download success, make sure that the call log is called into next.
+        callbackCaptor.getValue().onDownloadSuccessful(imageData);
+        ArgumentCaptor<OutcomeReceiver<Uri, CallLog.CallComposerLoggingException>>
+                callLogCallbackCaptor = ArgumentCaptor.forClass(OutcomeReceiver.class);
+        verify(mockCallLogProxy).storeCallComposerPictureAsUser(nullable(Context.class),
+                nullable(UserHandle.class), nullable(InputStream.class), nullable(Executor.class),
+                callLogCallbackCaptor.capture());
+
+        callLogCallbackCaptor.getValue().onResult(FAKE_CALLLOG_URI);
+        Uri receivedUri = callLogUriFuture.get(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+        assertEquals(FAKE_CALLLOG_URI, receivedUri);
+    }
+
+    @Test
+    public void testPictureDownloadWithAuthRefresh() throws Exception {
+        CallComposerPictureManager manager = CallComposerPictureManager.getInstance(context, 0);
+        manager.setCallLogProxy(mockCallLogProxy);
+
+        CompletableFuture<Uri> callLogUriFuture = new CompletableFuture<>();
+        manager.handleDownloadFromServer(new CallComposerPictureTransfer.Factory() {
+            @Override
+            public CallComposerPictureTransfer create(Context context, int subscriptionId,
+                    String url, ExecutorService executorService) {
+                return mockPictureTransfer;
+            }
+        }, FAKE_URL, (p) -> callLogUriFuture.complete(p.first));
+
+        // Get the callback for later manipulation
+        ArgumentCaptor<CallComposerPictureTransfer.PictureCallback> callbackCaptor =
+                ArgumentCaptor.forClass(CallComposerPictureTransfer.PictureCallback.class);
+        verify(mockPictureTransfer).setCallback(callbackCaptor.capture());
+
+        // Make sure the download method is called
+        verify(mockPictureTransfer).downloadPicture(nullable(GbaCredentialsSupplier.class));
+
+        // Simulate a auth-needed retry request
+        callbackCaptor.getValue().onRetryNeeded(true, 0);
+        waitForExecutorAction(CallComposerPictureManager.getExecutor(), TIMEOUT_MILLIS);
+
+        // Make sure download gets called again immediately, and make sure that the new GBA creds
+        // are requested with a force-refresh.
+        ArgumentCaptor<GbaCredentialsSupplier> credSupplierCaptor =
+                ArgumentCaptor.forClass(GbaCredentialsSupplier.class);
+        verify(mockPictureTransfer, times(2)).downloadPicture(credSupplierCaptor.capture());
+
+        testGbaCredLookup(credSupplierCaptor.getValue(), true);
+    }
+
+
+    public void testGbaCredLookup(GbaCredentialsSupplier supplier, boolean forceExpected)
+            throws Exception {
+        String fakeRealm = "3gpp-bootstraping@naf1.example.com";
+        byte[] fakeKey = new byte[] {1, 2, 3, 4, 5};
+        String fakeTxId = "89sdfjggf";
+
+        ArgumentCaptor<TelephonyManager.BootstrapAuthenticationCallback> authCallbackCaptor =
+                ArgumentCaptor.forClass(TelephonyManager.BootstrapAuthenticationCallback.class);
+
+        CompletableFuture<GbaCredentials> credsFuture =
+                supplier.getCredentials(fakeRealm, CallComposerPictureManager.getExecutor());
+        verify(telephonyManager).bootstrapAuthenticationRequest(anyInt(), eq(Uri.parse(fakeRealm)),
+                nullable(UaSecurityProtocolIdentifier.class), eq(forceExpected),
+                nullable(Executor.class),
+                authCallbackCaptor.capture());
+        authCallbackCaptor.getValue().onKeysAvailable(fakeKey, fakeTxId);
+        GbaCredentials creds = credsFuture.get(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+        assertEquals(fakeTxId, creds.getTransactionId());
+        assertArrayEquals(fakeKey, creds.getKey());
+
+
+        // Do it again and see if we make another request, then make sure that matches up with what
+        // we expected.
+        CompletableFuture<GbaCredentials> credsFuture1 =
+                supplier.getCredentials(fakeRealm, CallComposerPictureManager.getExecutor());
+        verify(telephonyManager, times(forceExpected ? 2 : 1))
+                .bootstrapAuthenticationRequest(anyInt(), eq(Uri.parse(fakeRealm)),
+                        nullable(UaSecurityProtocolIdentifier.class),
+                        eq(forceExpected),
+                        nullable(Executor.class),
+                        authCallbackCaptor.capture());
+        authCallbackCaptor.getValue().onKeysAvailable(fakeKey, fakeTxId);
+        GbaCredentials creds1 = credsFuture1.get(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+        assertEquals(fakeTxId, creds1.getTransactionId());
+        assertArrayEquals(fakeKey, creds1.getKey());
+    }
+
+    private static boolean waitForExecutorAction(
+            ExecutorService executorService, long timeoutMillis) {
+        CompletableFuture<Void> f = new CompletableFuture<>();
+        executorService.execute(() -> f.complete(null));
+        try {
+            f.get(timeoutMillis, TimeUnit.MILLISECONDS);
+        } catch (TimeoutException e) {
+            return false;
+        } catch (InterruptedException | ExecutionException e) {
+            throw new RuntimeException(e);
+        }
+        return true;
+    }
+}