blob: e4458cd8cd9c66ad96045872e09cc610adb4300e [file] [log] [blame]
/*
* 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.os.Build;
import android.telephony.TelephonyManager;
import android.util.Log;
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;
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.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Iterator;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
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 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) {}
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;
private PictureCallback mCallback;
private CallComposerPictureTransfer(Context context, int subscriptionId, String url,
ExecutorService executorService) {
mContext = context;
mSubscriptionId = subscriptionId;
mExecutorService = executorService;
mUrl = url;
}
@VisibleForTesting
public void setCallback(PictureCallback callback) {
mCallback = callback;
}
public void uploadPicture(ImageData image,
GbaCredentialsSupplier credentialsSupplier) {
CompletableFuture<Network> networkFuture = getNetworkForCallComposer();
CompletableFuture<WWWAuthenticate> authorizationHeaderFuture = networkFuture
.thenApplyAsync((network) -> prepareInitialPost(network, mUrl), mExecutorService)
.thenComposeAsync(this::obtainAuthenticateHeader, mExecutorService)
.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),
mExecutorService);
networkUrlFuture.thenAcceptAsync((result) -> {
if (result != null) mCallback.onUploadSuccessful(result);
}, mExecutorService).exceptionally((ex) -> {
logException("Exception uploading image" , ex);
return null;
});
}
public void downloadPicture(GbaCredentialsSupplier credentialsSupplier) {
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<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)
.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);
}).exceptionally((ex) -> {
logException("Exception downloading image" , ex);
return null;
});
}
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 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", getUserAgent());
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 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", getUserAgent());
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);
connection.addRequestProperty("Content-Length",
String.valueOf(multipartEntity.getContentLength()));
connection.addRequestProperty("Content-Type", multipartEntity.getContentType().getValue());
connection.addRequestProperty("Accept-Encoding", "*");
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();
Log.i(TAG, "Received response code: " + response
+ ", message=" + connection.getResponseMessage());
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 String getUserAgent() {
String carrierName = mContext.getSystemService(TelephonyManager.class)
.createForSubscriptionId(mSubscriptionId)
.getSimOperatorName();
String buildId = Build.ID;
String buildDate = DateTimeFormatter.ofPattern("yyyy-MM-dd")
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochMilli(Build.TIME));
String buildVersion = Build.VERSION.RELEASE_OR_CODENAME;
String deviceName = Build.DEVICE;
return String.format("%s %s %s %s %s %s %s",
carrierName, buildId, buildDate, "Android", buildVersion,
deviceName, THREE_GPP_GBA);
}
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());
}
}