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;
+ }
+}