Add code for communicating with carrier servers

Add code to
* Authenticate with carrier servers
* Upload pictures via HTTP
* Download pictures via HTTP

Test: atest CallComposerAuthTest
Bug: 175435766
Change-Id: I22362c694867c75ea209a021e383a08d730c7972
diff --git a/Android.bp b/Android.bp
index e7ca068..6f97d86 100644
--- a/Android.bp
+++ b/Android.bp
@@ -24,6 +24,7 @@
         "ims-common",
         "libprotobuf-java-lite",
         "unsupportedappusage",
+        "org.apache.http.legacy",
     ],
 
     static_libs: [
@@ -36,6 +37,7 @@
         "guava",
         "PlatformProperties",
         "modules-utils-os",
+        "nist-sip",
     ],
 
     srcs: [
diff --git a/proguard.flags b/proguard.flags
index c707f76..8eafd30 100644
--- a/proguard.flags
+++ b/proguard.flags
@@ -7,4 +7,9 @@
 -keepclassmembers class * {
 @**.NeededForTesting *;
 }
+# TODO: remove this after call composer gets more integrated.
+# for the time being, this is here so that the tests don't fail when encountering dead code.
+-keep class com.android.phone.callcomposer.** {
+ *;
+}
 -verbose
\ No newline at end of file
diff --git a/src/com/android/phone/PhoneInterfaceManager.java b/src/com/android/phone/PhoneInterfaceManager.java
index 8d665cb..62df1a6 100755
--- a/src/com/android/phone/PhoneInterfaceManager.java
+++ b/src/com/android/phone/PhoneInterfaceManager.java
@@ -6926,7 +6926,7 @@
 
     @Override
     public void uploadCallComposerPicture(int subscriptionId, String callingPackage,
-            ParcelFileDescriptor fd, ResultReceiver callback) {
+            String contentType, ParcelFileDescriptor fd, ResultReceiver callback) {
         try {
             if (!Objects.equals(mApp.getPackageManager().getPackageUid(callingPackage, 0),
                     Binder.getCallingUid())) {
diff --git a/src/com/android/phone/callcomposer/CallComposerPictureManager.java b/src/com/android/phone/callcomposer/CallComposerPictureManager.java
new file mode 100644
index 0000000..7ffeeef
--- /dev/null
+++ b/src/com/android/phone/callcomposer/CallComposerPictureManager.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2020 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 android.content.Context;
+import android.net.Uri;
+import android.util.Pair;
+import android.util.SparseArray;
+
+import java.util.HashMap;
+import java.util.UUID;
+import java.util.function.Consumer;
+
+public class CallComposerPictureManager {
+    private static final SparseArray<CallComposerPictureManager> sInstances = new SparseArray<>();
+
+    public static CallComposerPictureManager getInstance(Context context, int subscriptionId) {
+        synchronized (sInstances) {
+            if (!sInstances.contains(subscriptionId)) {
+                sInstances.put(subscriptionId,
+                        new CallComposerPictureManager(context, subscriptionId));
+            }
+            return sInstances.get(subscriptionId);
+        }
+    }
+
+    private HashMap<UUID, ImageData> mCachedPics = new HashMap<>();
+    private HashMap<UUID, String> mCachedServerUrls = new HashMap<>();
+    private GbaCredentials mCachedCredentials;
+    private final int mSubscriptionId;
+    private final Context mContext;
+
+    private CallComposerPictureManager(Context context, int subscriptionId) {
+        mContext = context;
+        mSubscriptionId = subscriptionId;
+    }
+
+    public void handleUploadToServer(ImageData imageData, Consumer<Pair<UUID, Integer>> callback) {
+        // TODO: plumbing
+    }
+
+    public void handleDownloadFromServer(String remoteUrl, Consumer<Pair<Uri, Integer>> callback) {
+        // TODO: plumbing, insert to call log
+    }
+}
diff --git a/src/com/android/phone/callcomposer/CallComposerPictureTransfer.java b/src/com/android/phone/callcomposer/CallComposerPictureTransfer.java
new file mode 100644
index 0000000..d56b7f1
--- /dev/null
+++ b/src/com/android/phone/callcomposer/CallComposerPictureTransfer.java
@@ -0,0 +1,475 @@
+/*
+ * Copyright (C) 2020 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 android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.NetworkRequest;
+import android.telephony.TelephonyManager;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import com.android.internal.http.multipart.MultipartEntity;
+import com.android.internal.http.multipart.Part;
+
+import com.google.common.net.MediaType;
+
+import org.xml.sax.InputSource;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.charset.Charset;
+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;
+import javax.xml.xpath.XPathConstants;
+import javax.xml.xpath.XPathExpressionException;
+import javax.xml.xpath.XPathFactory;
+
+public class CallComposerPictureTransfer {
+    private static final String TAG = CallComposerPictureTransfer.class.getSimpleName();
+    private static final int HTTP_TIMEOUT_MILLIS = 20000;
+    private static final int DEFAULT_BACKOFF_MILLIS = 1000;
+    private static final String THREE_GPP_GBA = "3gpp-gba";
+
+    private static final int ERROR_UNKNOWN = 0;
+    private static final int ERROR_HTTP_TIMEOUT = 1;
+    private static final int ERROR_NO_AUTH_REQUIRED = 2;
+    private static final int ERROR_FORBIDDEN = 3;
+
+    public interface PictureCallback {
+        default void onError(@TelephonyManager.CallComposerException.CallComposerError int error) {}
+        default void onRetryNeeded(boolean credentialRefresh, long backoffMillis) {}
+        default void onUploadSuccessful(String serverUrl) {}
+        default void onDownloadSuccessful(ImageData data) {}
+    }
+
+    private static class NetworkAccessException extends RuntimeException {
+        final int errorCode;
+
+        NetworkAccessException(int errorCode) {
+            this.errorCode = errorCode;
+        }
+    }
+
+    private final Context mContext;
+    private final int mSubscriptionId;
+    private final String mUrl;
+    private final ExecutorService mExecutorService = Executors.newSingleThreadExecutor();
+
+    // Only one of these is nonnull per instance.
+    private PictureCallback mCallback;
+
+    public CallComposerPictureTransfer(Context context, int subscriptionId, String url,
+            PictureCallback downloadCallback) {
+        mContext = context;
+        mSubscriptionId = subscriptionId;
+        mUrl = url;
+        mCallback = downloadCallback;
+    }
+
+    public void uploadPicture(ImageData image, GbaCredentials credentials) {
+        CompletableFuture<Network> networkFuture = getNetworkForCallComposer();
+        CompletableFuture<String> authorizationFuture = 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),
+                        mExecutorService);
+
+        CompletableFuture<String> networkUrlFuture =
+                networkFuture.thenCombineAsync(authorizationFuture,
+                        (network, auth) -> sendActualImageUpload(network, auth, image),
+                        mExecutorService);
+        networkUrlFuture.thenAcceptAsync((result) -> {
+            if (result != null) mCallback.onUploadSuccessful(result);
+        }, mExecutorService);
+    }
+
+    public void downloadPicture(GbaCredentials credentials) {
+        CompletableFuture<Network> networkFuture = getNetworkForCallComposer();
+        CompletableFuture<HttpURLConnection> getConnectionFuture =
+                networkFuture.thenApplyAsync((network) ->
+                        prepareImageDownloadRequest(network, mUrl), mExecutorService);
+
+        CompletableFuture<ImageData> immediatelyDownloadableImage = getConnectionFuture
+                .thenComposeAsync((conn) -> {
+                    try {
+                        if (conn.getResponseCode() != 200) {
+                            return CompletableFuture.completedFuture(null);
+                        }
+                    } catch (IOException e) {
+                        logException("IOException obtaining return code: ", e);
+                        throw new NetworkAccessException(ERROR_HTTP_TIMEOUT);
+                    }
+                    return CompletableFuture.completedFuture(downloadImageFromConnection(conn));
+                }, mExecutorService);
+
+        CompletableFuture<ImageData> authRequiredImage = getConnectionFuture
+                .thenComposeAsync((conn) -> {
+                    try {
+                        if (conn.getResponseCode() == 200) {
+                            // handled by above case
+                            return CompletableFuture.completedFuture(null);
+                        }
+                    } catch (IOException e) {
+                        logException("IOException obtaining return code: ", e);
+                        throw new NetworkAccessException(ERROR_HTTP_TIMEOUT);
+                    }
+                    CompletableFuture<String> authorizationFuture = obtainAuthenticateHeader(conn)
+                            .thenApplyAsync((authHeader) ->
+                                    DigestAuthUtils.generateAuthorizationHeader(
+                                            authHeader, credentials, "GET", mUrl), mExecutorService)
+                            .whenCompleteAsync((authorization, error) ->
+                                    handleExceptionalCompletion(error), mExecutorService);
+                    return networkFuture.thenCombineAsync(authorizationFuture,
+                            this::downloadImageWithAuth, mExecutorService);
+                }, mExecutorService);
+
+        CompletableFuture.allOf(immediatelyDownloadableImage, authRequiredImage).thenRun(() -> {
+            ImageData fromImmediate = immediatelyDownloadableImage.getNow(null);
+            ImageData fromAuth = authRequiredImage.getNow(null);
+            // If both of these are null, that means an error happened somewhere in the chain.
+            // in that case, the error has already been transmitted to the callback, so ignore it.
+            if (fromAuth == null && fromImmediate == null) {
+                Log.w(TAG, "No result from download -- error happened sometime earlier");
+            }
+            if (fromAuth != null) mCallback.onDownloadSuccessful(fromAuth);
+            mCallback.onDownloadSuccessful(fromImmediate);
+        });
+    }
+
+    public void shutdown() {
+        mExecutorService.shutdown();
+    }
+
+    private CompletableFuture<Network> getNetworkForCallComposer() {
+        ConnectivityManager connectivityManager =
+                mContext.getSystemService(ConnectivityManager.class);
+        NetworkRequest pictureNetworkRequest = new NetworkRequest.Builder()
+                .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+                .build();
+        CompletableFuture<Network> resultFuture = new CompletableFuture<>();
+        connectivityManager.requestNetwork(pictureNetworkRequest,
+                new ConnectivityManager.NetworkCallback() {
+                    @Override
+                    public void onAvailable(@NonNull Network network) {
+                        resultFuture.complete(network);
+                    }
+                });
+        return resultFuture;
+    }
+
+    private static HttpURLConnection prepareInitialPost(Network network, String uploadUrl) {
+        try {
+            HttpURLConnection connection =
+                    (HttpURLConnection) network.openConnection(new URL(uploadUrl));
+            connection.setRequestMethod("POST");
+            connection.setInstanceFollowRedirects(false);
+            connection.setConnectTimeout(HTTP_TIMEOUT_MILLIS);
+            connection.setReadTimeout(HTTP_TIMEOUT_MILLIS);
+            connection.setRequestProperty("User-Agent", THREE_GPP_GBA);
+            return connection;
+        } catch (MalformedURLException e) {
+            Log.e(TAG, "Malformed URL: " + uploadUrl);
+            throw new RuntimeException(e);
+        } catch (IOException e) {
+            logException("IOException opening network: ", e);
+            throw new RuntimeException(e);
+        }
+    }
+
+    private static HttpURLConnection prepareImageDownloadRequest(Network network, String imageUrl) {
+        try {
+            HttpURLConnection connection =
+                    (HttpURLConnection) network.openConnection(new URL(imageUrl));
+            connection.setRequestMethod("GET");
+            connection.setConnectTimeout(HTTP_TIMEOUT_MILLIS);
+            connection.setReadTimeout(HTTP_TIMEOUT_MILLIS);
+            connection.setRequestProperty("User-Agent", THREE_GPP_GBA);
+            return connection;
+        } catch (MalformedURLException e) {
+            Log.e(TAG, "Malformed URL: " + imageUrl);
+            throw new RuntimeException(e);
+        } catch (IOException e) {
+            logException("IOException opening network: ", e);
+            throw new RuntimeException(e);
+        }
+    }
+
+    // Attempts to connect via the supplied connection, expecting a HTTP 401 in response. Throws
+    // an IOException if the connection times out.
+    // After the response is received, returns the WWW-Authenticate header in the following form:
+    // "WWW-Authenticate:<method> <params>"
+    private CompletableFuture<String> obtainAuthenticateHeader(
+            HttpURLConnection connection) {
+        return CompletableFuture.supplyAsync(() -> {
+            int responseCode;
+            try {
+                responseCode = connection.getResponseCode();
+            } catch (IOException e) {
+                logException("IOException obtaining auth header: ", e);
+                throw new NetworkAccessException(ERROR_HTTP_TIMEOUT);
+            }
+            if (responseCode == 204) {
+                throw new NetworkAccessException(ERROR_NO_AUTH_REQUIRED);
+            } else if (responseCode == 403) {
+                throw new NetworkAccessException(ERROR_FORBIDDEN);
+            } else if (responseCode != 401) {
+                Log.w(TAG, "Received unexpected response in auth request, code= "
+                        + responseCode);
+                throw new NetworkAccessException(ERROR_UNKNOWN);
+            }
+
+            return connection.getHeaderField(DigestAuthUtils.WWW_AUTHENTICATE);
+        }, mExecutorService);
+    }
+
+    private ImageData downloadImageWithAuth(Network network, String authorization) {
+        HttpURLConnection connection = prepareImageDownloadRequest(network, mUrl);
+        connection.addRequestProperty("Authorization", authorization);
+        return downloadImageFromConnection(connection);
+    }
+
+    private ImageData downloadImageFromConnection(HttpURLConnection conn) {
+        try {
+            if (conn.getResponseCode() != 200) {
+                Log.w(TAG, "Got response code " + conn.getResponseCode() + " when trying"
+                        + " to download image");
+                if (conn.getResponseCode() == 401) {
+                    Log.i(TAG, "Got 401 even with auth -- key refresh needed?");
+                    mCallback.onRetryNeeded(true, 0);
+                }
+                return null;
+            }
+        } catch (IOException e) {
+            logException("IOException obtaining return code: ", e);
+            throw new NetworkAccessException(ERROR_HTTP_TIMEOUT);
+        }
+
+        String contentType = conn.getContentType();
+        ByteArrayOutputStream imageDataOut = new ByteArrayOutputStream();
+        byte[] buffer = new byte[4096];
+        int numRead;
+        try {
+            InputStream is = conn.getInputStream();
+            while (true) {
+                numRead = is.read(buffer);
+                if (numRead < 0) break;
+                imageDataOut.write(buffer, 0, numRead);
+            }
+        } catch (IOException e) {
+            logException("IOException reading from image body: ", e);
+            return null;
+        }
+
+        return new ImageData(imageDataOut.toByteArray(), contentType, null);
+    }
+
+    private void handleExceptionalCompletion(Throwable error) {
+        if (error != null) {
+            if (error.getCause() instanceof NetworkAccessException) {
+                int code = ((NetworkAccessException) error.getCause()).errorCode;
+                if (code == ERROR_UNKNOWN || code == ERROR_HTTP_TIMEOUT) {
+                    scheduleRetry();
+                } else {
+                    int failureCode;
+                    if (code == ERROR_FORBIDDEN) {
+                        failureCode = TelephonyManager.CallComposerException
+                                .ERROR_AUTHENTICATION_FAILED;
+                    } else {
+                        failureCode = TelephonyManager.CallComposerException
+                                .ERROR_UNKNOWN;
+                    }
+                    deliverFailure(failureCode);
+                }
+            } else {
+                deliverFailure(TelephonyManager.CallComposerException.ERROR_UNKNOWN);
+            }
+        }
+    }
+
+    private void scheduleRetry() {
+        mCallback.onRetryNeeded(false, DEFAULT_BACKOFF_MILLIS);
+    }
+
+    private void deliverFailure(int code) {
+        mCallback.onError(code);
+    }
+
+    private static Part makeUploadPart(String name, String contentType, String filename,
+            byte[] data) {
+        return new Part() {
+            @Override
+            public String getName() {
+                return name;
+            }
+
+            @Override
+            public String getContentType() {
+                return contentType;
+            }
+
+            @Override
+            public String getCharSet() {
+                return null;
+            }
+
+            @Override
+            public String getTransferEncoding() {
+                return null;
+            }
+
+            @Override
+            public void sendDispositionHeader(OutputStream out) throws IOException {
+                super.sendDispositionHeader(out);
+                if (filename != null) {
+                    String fileNameSuffix = ";filename=\"" + filename + "\"";
+                    out.write(fileNameSuffix.getBytes());
+                }
+            }
+
+            @Override
+            protected void sendData(OutputStream out) throws IOException {
+                out.write(data);
+            }
+
+            @Override
+            protected long lengthOfData() throws IOException {
+                return data.length;
+            }
+        };
+    }
+
+    private String sendActualImageUpload(Network network, String authHeader, ImageData image) {
+        Part transactionIdPart = makeUploadPart("tid", "text/plain",
+                null, image.getId().getBytes());
+        Part imageDataPart = makeUploadPart("File", image.getMimeType(),
+                image.getId(), image.getImageBytes());
+
+        MultipartEntity multipartEntity =
+                new MultipartEntity(new Part[] {transactionIdPart, imageDataPart});
+
+        HttpURLConnection connection = prepareInitialPost(network, mUrl);
+        connection.setDoOutput(true);
+        connection.addRequestProperty("Authorization", authHeader);
+        try (OutputStream requestBodyOut = connection.getOutputStream()) {
+            multipartEntity.writeTo(requestBodyOut);
+        } catch (IOException e) {
+            logException("IOException making request to upload image: ", e);
+            throw new RuntimeException(e);
+        }
+
+        try {
+            int response = connection.getResponseCode();
+            if (response == 401 || response == 403) {
+                deliverFailure(TelephonyManager.CallComposerException.ERROR_AUTHENTICATION_FAILED);
+                return null;
+            }
+            if (response == 503) {
+                // TODO: implement parsing of retry-after and schedule a retry with that time
+                scheduleRetry();
+                return null;
+            }
+            if (response != 200) {
+                scheduleRetry();
+                return null;
+            }
+            String responseBody = readResponseBody(connection);
+            String parsedUrl = parseImageUploadResponseXmlForUrl(responseBody);
+            Log.i(TAG, "Parsed URL as upload result: " + parsedUrl);
+            return parsedUrl;
+        } catch (IOException e) {
+            logException("IOException getting response to image upload: ", e);
+            deliverFailure(TelephonyManager.CallComposerException.ERROR_UNKNOWN);
+            return null;
+        }
+    }
+
+    private static String parseImageUploadResponseXmlForUrl(String xmlData) {
+        NamespaceContext ns = new NamespaceContext() {
+            public String getNamespaceURI(String prefix) {
+                return "urn:gsma:params:xml:ns:rcs:rcs:fthttp";
+            }
+
+            public String getPrefix(String uri) {
+                throw new UnsupportedOperationException();
+            }
+
+            public Iterator getPrefixes(String uri) {
+                throw new UnsupportedOperationException();
+            }
+        };
+
+        XPath xPath = XPathFactory.newInstance().newXPath();
+        xPath.setNamespaceContext(ns);
+        StringReader reader = new StringReader(xmlData);
+        try {
+            return (String) xPath.evaluate("/a:file/a:file-info[@type='file']/a:data/@url",
+                    new InputSource(reader), XPathConstants.STRING);
+        } catch (XPathExpressionException e) {
+            logException("Error parsing response XML:", e);
+            return null;
+        }
+    }
+
+    private static String readResponseBody(HttpURLConnection connection) {
+        Charset charset = MediaType.parse(connection.getContentType())
+                .charset().or(Charset.defaultCharset());
+        StringBuilder sb = new StringBuilder();
+        try (InputStream inputStream = connection.getInputStream()) {
+            String outLine;
+            BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, charset));
+            while ((outLine = reader.readLine()) != null) {
+                sb.append(outLine);
+            }
+        } catch (IOException e) {
+            logException("IOException reading request body: ", e);
+            return null;
+        }
+        return sb.toString();
+    }
+
+    private static void logException(String message, Throwable e) {
+        StringWriter log = new StringWriter();
+        log.append(message);
+        log.append(":\n");
+        log.append(e.getMessage());
+        PrintWriter pw = new PrintWriter(log);
+        e.printStackTrace(pw);
+        Log.e(TAG, log.toString());
+    }
+}
diff --git a/src/com/android/phone/callcomposer/DigestAuthUtils.java b/src/com/android/phone/callcomposer/DigestAuthUtils.java
new file mode 100644
index 0000000..a9f589c
--- /dev/null
+++ b/src/com/android/phone/callcomposer/DigestAuthUtils.java
@@ -0,0 +1,149 @@
+/*
+ * 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 android.text.TextUtils;
+import android.util.Log;
+
+import com.google.common.io.BaseEncoding;
+
+import gov.nist.javax.sip.address.GenericURI;
+import gov.nist.javax.sip.header.Authorization;
+import gov.nist.javax.sip.header.WWWAuthenticate;
+import gov.nist.javax.sip.parser.WWWAuthenticateParser;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.text.ParseException;
+
+public class DigestAuthUtils {
+    private static final String TAG = DigestAuthUtils.class.getSimpleName();
+
+    public static final String WWW_AUTHENTICATE = "www-authenticate";
+    private static final String MD5_ALGORITHM = "md5";
+    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;
+        WWWAuthenticate parsedHeader;
+        try {
+            parsedHeader =
+                    (WWWAuthenticate) (new WWWAuthenticateParser(reconstitutedHeader).parse());
+        } catch (ParseException e) {
+            Log.e(TAG, "Error parsing received auth header: " + e);
+            return null;
+        }
+        if (!TextUtils.isEmpty(parsedHeader.getAlgorithm())
+                && !MD5_ALGORITHM.equals(parsedHeader.getAlgorithm().toLowerCase())) {
+            Log.e(TAG, "This client only supports MD5 auth");
+        }
+
+        Log.i(TAG, "nonce=" + parsedHeader.getNonce());
+
+        String clientNonce = makeClientNonce();
+
+        String response = computeResponse(parsedHeader.getNonce(), clientNonce, AUTH_QOP,
+                credentials.getTransactionId(), parsedHeader.getRealm(), credentials.getKey(),
+                method, uri);
+
+        Authorization replyHeader = new Authorization();
+        try {
+            replyHeader.setScheme(parsedHeader.getScheme());
+            replyHeader.setUsername(credentials.getTransactionId());
+            replyHeader.setURI(new WorkaroundURI(uri));
+            replyHeader.setQop(AUTH_QOP);
+            replyHeader.setCNonce(clientNonce);
+            replyHeader.setNonceCount(1);
+            replyHeader.setResponse(response);
+            replyHeader.setOpaque(parsedHeader.getOpaque());
+            replyHeader.setAlgorithm(parsedHeader.getAlgorithm());
+
+        } catch (ParseException e) {
+            Log.e(TAG, "Error parsing while constructing reply header: " + e);
+            return null;
+        }
+
+        return replyHeader.encode();
+    }
+
+    public static String computeResponse(String serverNonce, String clientNonce, String qop,
+            String username, String realm, byte[] password, String method, String uri) {
+        String a1Hash = generateA1Hash(username, realm, password);
+        String a2Hash = generateA2Hash(method, uri);
+
+        // this is the nonce-count; since we don't reuse, it's always 1
+        String nonceCount = "00000001";
+        MessageDigest md5Digest = getMd5Digest();
+
+        String hashInput = String.join(":",
+                a1Hash,
+                serverNonce,
+                nonceCount,
+                clientNonce,
+                qop,
+                a2Hash);
+        md5Digest.update(hashInput.getBytes());
+        return base16(md5Digest.digest());
+    }
+
+    private static String makeClientNonce() {
+        SecureRandom rand = new SecureRandom();
+        byte[] clientNonceBytes = new byte[CNONCE_LENGTH_BYTES];
+        rand.nextBytes(clientNonceBytes);
+        return base16(clientNonceBytes);
+    }
+
+    private static String generateA1Hash(
+            String bootstrapTransactionId, String realm, byte[] gbaKey) {
+        MessageDigest md5Digest = getMd5Digest();
+
+        String gbaKeyBase64 = BaseEncoding.base64().encode(gbaKey);
+        String hashInput = String.join(":", bootstrapTransactionId, realm, gbaKeyBase64);
+        md5Digest.update(hashInput.getBytes());
+
+        return base16(md5Digest.digest());
+    }
+
+    private static String generateA2Hash(String method, String requestUri) {
+        MessageDigest md5Digest = getMd5Digest();
+        md5Digest.update(String.join(":", method, requestUri).getBytes());
+        return base16(md5Digest.digest());
+    }
+
+    private static String base16(byte[] input) {
+        return BaseEncoding.base16().encode(input).toLowerCase();
+    }
+
+    private static MessageDigest getMd5Digest() {
+        try {
+            return MessageDigest.getInstance("MD5");
+        } catch (NoSuchAlgorithmException e) {
+            throw new RuntimeException("Couldn't find MD5 algorithm: " + e);
+        }
+    }
+
+    private static class WorkaroundURI extends GenericURI {
+        public WorkaroundURI(String uriString) {
+            this.uriString = uriString;
+            this.scheme = "";
+        }
+    }
+}
diff --git a/src/com/android/phone/callcomposer/GbaCredentials.java b/src/com/android/phone/callcomposer/GbaCredentials.java
new file mode 100644
index 0000000..25a0cd5
--- /dev/null
+++ b/src/com/android/phone/callcomposer/GbaCredentials.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2020 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;
+
+public class GbaCredentials {
+    private final String mTransactionId;
+    private final byte[] mKey;
+
+    public GbaCredentials(String transactionId, byte[] key) {
+        mTransactionId = transactionId;
+        mKey = key;
+    }
+
+    public String getTransactionId() {
+        return mTransactionId;
+    }
+
+    public byte[] getKey() {
+        return mKey;
+    }
+}
diff --git a/src/com/android/phone/callcomposer/ImageData.java b/src/com/android/phone/callcomposer/ImageData.java
new file mode 100644
index 0000000..77a61c0
--- /dev/null
+++ b/src/com/android/phone/callcomposer/ImageData.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2020 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;
+
+public class ImageData {
+    private final byte[] mImageBytes;
+    private final String mMimeType;
+    private String mId;
+
+    public ImageData(byte[] imageBytes, String mimeType, String id) {
+        mImageBytes = imageBytes;
+        mMimeType = mimeType;
+        mId = id;
+    }
+
+    public byte[] getImageBytes() {
+        return mImageBytes;
+    }
+
+    public String getMimeType() {
+        return mMimeType;
+    }
+
+    public String getId() {
+        return mId;
+    }
+}
diff --git a/tests/src/com/android/phone/CallComposerAuthTest.java b/tests/src/com/android/phone/CallComposerAuthTest.java
new file mode 100644
index 0000000..9e253a0
--- /dev/null
+++ b/tests/src/com/android/phone/CallComposerAuthTest.java
@@ -0,0 +1,43 @@
+/*
+ * 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;
+
+import static org.junit.Assert.assertEquals;
+
+import com.android.phone.callcomposer.DigestAuthUtils;
+
+import org.junit.Test;
+
+public class CallComposerAuthTest {
+    @Test
+    public void testResponseGeneration() {
+        String username = "test1";
+        String realm = "test@test.com";
+        byte[] password = "12345678".getBytes();
+        String sNonce = "aaaabbbbcccc";
+        String cNonce = "ccccbbbbaaaa";
+        String ncValue = "00000001";
+        String method = "POST";
+        String uri = "/test/test1?a=b";
+        String qop = "auth";
+
+        String response = DigestAuthUtils.computeResponse(sNonce, cNonce, qop, username,
+                realm, password, method, uri);
+        // precomputed response value from a known-good implementation
+        assertEquals("744d63d6fb11aa132dc906ec95306960", response);
+    }
+}