/* * 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 networkFuture = getNetworkForCallComposer(); CompletableFuture authorizationHeaderFuture = networkFuture .thenApplyAsync((network) -> prepareInitialPost(network, mUrl), mExecutorService) .thenComposeAsync(this::obtainAuthenticateHeader, mExecutorService) .thenApplyAsync(DigestAuthUtils::parseAuthenticateHeader); CompletableFuture credsFuture = authorizationHeaderFuture .thenComposeAsync((header) -> credentialsSupplier.getCredentials(header.getRealm(), mExecutorService), mExecutorService); CompletableFuture authorizationFuture = authorizationHeaderFuture.thenCombineAsync(credsFuture, (authHeader, credentials) -> DigestAuthUtils.generateAuthorizationHeader( authHeader, credentials, "POST", mUrl), mExecutorService) .whenCompleteAsync( (authorization, error) -> handleExceptionalCompletion(error), mExecutorService); CompletableFuture 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 networkFuture = getNetworkForCallComposer(); CompletableFuture getConnectionFuture = networkFuture.thenApplyAsync((network) -> prepareImageDownloadRequest(network, mUrl), mExecutorService); CompletableFuture 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 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 authenticateHeaderFuture = obtainAuthenticateHeader(conn) .thenApply(DigestAuthUtils::parseAuthenticateHeader); CompletableFuture credsFuture = authenticateHeaderFuture .thenComposeAsync((header) -> credentialsSupplier.getCredentials(header.getRealm(), mExecutorService), mExecutorService); CompletableFuture 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 getNetworkForCallComposer() { ConnectivityManager connectivityManager = mContext.getSystemService(ConnectivityManager.class); NetworkRequest pictureNetworkRequest = new NetworkRequest.Builder() .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .build(); CompletableFuture 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: " private CompletableFuture 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()); } }