1 /* 2 * Copyright (C) 2020 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.phone.callcomposer; 18 19 import android.content.Context; 20 import android.net.ConnectivityManager; 21 import android.net.Network; 22 import android.net.NetworkCapabilities; 23 import android.net.NetworkRequest; 24 import android.os.Build; 25 import android.telephony.TelephonyManager; 26 import android.util.Log; 27 28 import androidx.annotation.NonNull; 29 30 import com.android.internal.annotations.VisibleForTesting; 31 import com.android.internal.http.multipart.MultipartEntity; 32 import com.android.internal.http.multipart.Part; 33 34 import com.google.common.net.MediaType; 35 36 import gov.nist.javax.sip.header.WWWAuthenticate; 37 38 import org.xml.sax.InputSource; 39 40 import java.io.BufferedReader; 41 import java.io.ByteArrayOutputStream; 42 import java.io.IOException; 43 import java.io.InputStream; 44 import java.io.InputStreamReader; 45 import java.io.OutputStream; 46 import java.io.PrintWriter; 47 import java.io.StringReader; 48 import java.io.StringWriter; 49 import java.net.HttpURLConnection; 50 import java.net.MalformedURLException; 51 import java.net.URL; 52 import java.nio.charset.Charset; 53 import java.time.Instant; 54 import java.time.ZoneId; 55 import java.time.format.DateTimeFormatter; 56 import java.util.Iterator; 57 import java.util.concurrent.CompletableFuture; 58 import java.util.concurrent.ExecutorService; 59 60 import javax.xml.namespace.NamespaceContext; 61 import javax.xml.xpath.XPath; 62 import javax.xml.xpath.XPathConstants; 63 import javax.xml.xpath.XPathExpressionException; 64 import javax.xml.xpath.XPathFactory; 65 66 public class CallComposerPictureTransfer { 67 private static final String TAG = CallComposerPictureTransfer.class.getSimpleName(); 68 private static final int HTTP_TIMEOUT_MILLIS = 20000; 69 private static final int DEFAULT_BACKOFF_MILLIS = 1000; 70 private static final String THREE_GPP_GBA = "3gpp-gba"; 71 72 private static final int ERROR_UNKNOWN = 0; 73 private static final int ERROR_HTTP_TIMEOUT = 1; 74 private static final int ERROR_NO_AUTH_REQUIRED = 2; 75 private static final int ERROR_FORBIDDEN = 3; 76 77 public interface Factory { create(Context context, int subscriptionId, String url, ExecutorService executorService)78 default CallComposerPictureTransfer create(Context context, int subscriptionId, String url, 79 ExecutorService executorService) { 80 return new CallComposerPictureTransfer(context, subscriptionId, url, executorService); 81 } 82 } 83 84 public interface PictureCallback { onError(@elephonyManager.CallComposerException.CallComposerError int error)85 default void onError(@TelephonyManager.CallComposerException.CallComposerError int error) {} onRetryNeeded(boolean credentialRefresh, long backoffMillis)86 default void onRetryNeeded(boolean credentialRefresh, long backoffMillis) {} onUploadSuccessful(String serverUrl)87 default void onUploadSuccessful(String serverUrl) {} onDownloadSuccessful(ImageData data)88 default void onDownloadSuccessful(ImageData data) {} 89 } 90 91 private static class NetworkAccessException extends RuntimeException { 92 final int errorCode; 93 NetworkAccessException(int errorCode)94 NetworkAccessException(int errorCode) { 95 this.errorCode = errorCode; 96 } 97 } 98 99 private final Context mContext; 100 private final int mSubscriptionId; 101 private final String mUrl; 102 private final ExecutorService mExecutorService; 103 104 private PictureCallback mCallback; 105 CallComposerPictureTransfer(Context context, int subscriptionId, String url, ExecutorService executorService)106 private CallComposerPictureTransfer(Context context, int subscriptionId, String url, 107 ExecutorService executorService) { 108 mContext = context; 109 mSubscriptionId = subscriptionId; 110 mExecutorService = executorService; 111 mUrl = url; 112 } 113 114 @VisibleForTesting setCallback(PictureCallback callback)115 public void setCallback(PictureCallback callback) { 116 mCallback = callback; 117 } 118 uploadPicture(ImageData image, GbaCredentialsSupplier credentialsSupplier)119 public void uploadPicture(ImageData image, 120 GbaCredentialsSupplier credentialsSupplier) { 121 CompletableFuture<Network> networkFuture = getNetworkForCallComposer(); 122 CompletableFuture<WWWAuthenticate> authorizationHeaderFuture = networkFuture 123 .thenApplyAsync((network) -> prepareInitialPost(network, mUrl), mExecutorService) 124 .thenComposeAsync(this::obtainAuthenticateHeader, mExecutorService) 125 .thenApplyAsync(DigestAuthUtils::parseAuthenticateHeader); 126 CompletableFuture<GbaCredentials> credsFuture = authorizationHeaderFuture 127 .thenComposeAsync((header) -> 128 credentialsSupplier.getCredentials(header.getRealm(), mExecutorService), 129 mExecutorService); 130 131 CompletableFuture<String> authorizationFuture = 132 authorizationHeaderFuture.thenCombineAsync(credsFuture, 133 (authHeader, credentials) -> 134 DigestAuthUtils.generateAuthorizationHeader( 135 authHeader, credentials, "POST", mUrl), 136 mExecutorService) 137 .whenCompleteAsync( 138 (authorization, error) -> handleExceptionalCompletion(error), 139 mExecutorService); 140 141 CompletableFuture<String> networkUrlFuture = 142 networkFuture.thenCombineAsync(authorizationFuture, 143 (network, auth) -> sendActualImageUpload(network, auth, image), 144 mExecutorService); 145 networkUrlFuture.thenAcceptAsync((result) -> { 146 if (result != null) mCallback.onUploadSuccessful(result); 147 }, mExecutorService).exceptionally((ex) -> { 148 logException("Exception uploading image" , ex); 149 return null; 150 }); 151 } 152 downloadPicture(GbaCredentialsSupplier credentialsSupplier)153 public void downloadPicture(GbaCredentialsSupplier credentialsSupplier) { 154 CompletableFuture<Network> networkFuture = getNetworkForCallComposer(); 155 CompletableFuture<HttpURLConnection> getConnectionFuture = 156 networkFuture.thenApplyAsync((network) -> 157 prepareImageDownloadRequest(network, mUrl), mExecutorService); 158 159 CompletableFuture<ImageData> immediatelyDownloadableImage = getConnectionFuture 160 .thenComposeAsync((conn) -> { 161 try { 162 if (conn.getResponseCode() != 200) { 163 return CompletableFuture.completedFuture(null); 164 } 165 } catch (IOException e) { 166 logException("IOException obtaining return code: ", e); 167 throw new NetworkAccessException(ERROR_HTTP_TIMEOUT); 168 } 169 return CompletableFuture.completedFuture(downloadImageFromConnection(conn)); 170 }, mExecutorService); 171 172 CompletableFuture<ImageData> authRequiredImage = getConnectionFuture 173 .thenComposeAsync((conn) -> { 174 try { 175 if (conn.getResponseCode() == 200) { 176 // handled by above case 177 return CompletableFuture.completedFuture(null); 178 } 179 } catch (IOException e) { 180 logException("IOException obtaining return code: ", e); 181 throw new NetworkAccessException(ERROR_HTTP_TIMEOUT); 182 } 183 CompletableFuture<WWWAuthenticate> authenticateHeaderFuture = 184 obtainAuthenticateHeader(conn) 185 .thenApply(DigestAuthUtils::parseAuthenticateHeader); 186 CompletableFuture<GbaCredentials> credsFuture = authenticateHeaderFuture 187 .thenComposeAsync((header) -> 188 credentialsSupplier.getCredentials(header.getRealm(), 189 mExecutorService), mExecutorService); 190 191 CompletableFuture<String> authorizationFuture = authenticateHeaderFuture 192 .thenCombineAsync(credsFuture, (authHeader, credentials) -> 193 DigestAuthUtils.generateAuthorizationHeader( 194 authHeader, credentials, "GET", mUrl), 195 mExecutorService) 196 .whenCompleteAsync((authorization, error) -> 197 handleExceptionalCompletion(error), mExecutorService); 198 199 return networkFuture.thenCombineAsync(authorizationFuture, 200 this::downloadImageWithAuth, mExecutorService); 201 }, mExecutorService); 202 203 CompletableFuture.allOf(immediatelyDownloadableImage, authRequiredImage).thenRun(() -> { 204 ImageData fromImmediate = immediatelyDownloadableImage.getNow(null); 205 ImageData fromAuth = authRequiredImage.getNow(null); 206 // If both of these are null, that means an error happened somewhere in the chain. 207 // in that case, the error has already been transmitted to the callback, so ignore it. 208 if (fromAuth == null && fromImmediate == null) { 209 Log.w(TAG, "No result from download -- error happened sometime earlier"); 210 } 211 if (fromAuth != null) mCallback.onDownloadSuccessful(fromAuth); 212 mCallback.onDownloadSuccessful(fromImmediate); 213 }).exceptionally((ex) -> { 214 logException("Exception downloading image" , ex); 215 return null; 216 }); 217 } 218 getNetworkForCallComposer()219 private CompletableFuture<Network> getNetworkForCallComposer() { 220 ConnectivityManager connectivityManager = 221 mContext.getSystemService(ConnectivityManager.class); 222 NetworkRequest pictureNetworkRequest = new NetworkRequest.Builder() 223 .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) 224 .build(); 225 CompletableFuture<Network> resultFuture = new CompletableFuture<>(); 226 connectivityManager.requestNetwork(pictureNetworkRequest, 227 new ConnectivityManager.NetworkCallback() { 228 @Override 229 public void onAvailable(@NonNull Network network) { 230 resultFuture.complete(network); 231 } 232 }); 233 return resultFuture; 234 } 235 prepareInitialPost(Network network, String uploadUrl)236 private HttpURLConnection prepareInitialPost(Network network, String uploadUrl) { 237 try { 238 HttpURLConnection connection = 239 (HttpURLConnection) network.openConnection(new URL(uploadUrl)); 240 connection.setRequestMethod("POST"); 241 connection.setInstanceFollowRedirects(false); 242 connection.setConnectTimeout(HTTP_TIMEOUT_MILLIS); 243 connection.setReadTimeout(HTTP_TIMEOUT_MILLIS); 244 connection.setRequestProperty("User-Agent", getUserAgent()); 245 return connection; 246 } catch (MalformedURLException e) { 247 Log.e(TAG, "Malformed URL: " + uploadUrl); 248 throw new RuntimeException(e); 249 } catch (IOException e) { 250 logException("IOException opening network: ", e); 251 throw new RuntimeException(e); 252 } 253 } 254 prepareImageDownloadRequest(Network network, String imageUrl)255 private HttpURLConnection prepareImageDownloadRequest(Network network, String imageUrl) { 256 try { 257 HttpURLConnection connection = 258 (HttpURLConnection) network.openConnection(new URL(imageUrl)); 259 connection.setRequestMethod("GET"); 260 connection.setConnectTimeout(HTTP_TIMEOUT_MILLIS); 261 connection.setReadTimeout(HTTP_TIMEOUT_MILLIS); 262 connection.setRequestProperty("User-Agent", getUserAgent()); 263 return connection; 264 } catch (MalformedURLException e) { 265 Log.e(TAG, "Malformed URL: " + imageUrl); 266 throw new RuntimeException(e); 267 } catch (IOException e) { 268 logException("IOException opening network: ", e); 269 throw new RuntimeException(e); 270 } 271 } 272 273 // Attempts to connect via the supplied connection, expecting a HTTP 401 in response. Throws 274 // an IOException if the connection times out. 275 // After the response is received, returns the WWW-Authenticate header in the following form: 276 // "WWW-Authenticate:<method> <params>" obtainAuthenticateHeader( HttpURLConnection connection)277 private CompletableFuture<String> obtainAuthenticateHeader( 278 HttpURLConnection connection) { 279 return CompletableFuture.supplyAsync(() -> { 280 int responseCode; 281 try { 282 responseCode = connection.getResponseCode(); 283 } catch (IOException e) { 284 logException("IOException obtaining auth header: ", e); 285 throw new NetworkAccessException(ERROR_HTTP_TIMEOUT); 286 } 287 if (responseCode == 204) { 288 throw new NetworkAccessException(ERROR_NO_AUTH_REQUIRED); 289 } else if (responseCode == 403) { 290 throw new NetworkAccessException(ERROR_FORBIDDEN); 291 } else if (responseCode != 401) { 292 Log.w(TAG, "Received unexpected response in auth request, code= " 293 + responseCode); 294 throw new NetworkAccessException(ERROR_UNKNOWN); 295 } 296 297 return connection.getHeaderField(DigestAuthUtils.WWW_AUTHENTICATE); 298 }, mExecutorService); 299 } 300 downloadImageWithAuth(Network network, String authorization)301 private ImageData downloadImageWithAuth(Network network, String authorization) { 302 HttpURLConnection connection = prepareImageDownloadRequest(network, mUrl); 303 connection.addRequestProperty("Authorization", authorization); 304 return downloadImageFromConnection(connection); 305 } 306 downloadImageFromConnection(HttpURLConnection conn)307 private ImageData downloadImageFromConnection(HttpURLConnection conn) { 308 try { 309 if (conn.getResponseCode() != 200) { 310 Log.w(TAG, "Got response code " + conn.getResponseCode() + " when trying" 311 + " to download image"); 312 if (conn.getResponseCode() == 401) { 313 Log.i(TAG, "Got 401 even with auth -- key refresh needed?"); 314 mCallback.onRetryNeeded(true, 0); 315 } 316 return null; 317 } 318 } catch (IOException e) { 319 logException("IOException obtaining return code: ", e); 320 throw new NetworkAccessException(ERROR_HTTP_TIMEOUT); 321 } 322 323 String contentType = conn.getContentType(); 324 ByteArrayOutputStream imageDataOut = new ByteArrayOutputStream(); 325 byte[] buffer = new byte[4096]; 326 int numRead; 327 try { 328 InputStream is = conn.getInputStream(); 329 while (true) { 330 numRead = is.read(buffer); 331 if (numRead < 0) break; 332 imageDataOut.write(buffer, 0, numRead); 333 } 334 } catch (IOException e) { 335 logException("IOException reading from image body: ", e); 336 return null; 337 } 338 339 return new ImageData(imageDataOut.toByteArray(), contentType, null); 340 } 341 handleExceptionalCompletion(Throwable error)342 private void handleExceptionalCompletion(Throwable error) { 343 if (error != null) { 344 if (error.getCause() instanceof NetworkAccessException) { 345 int code = ((NetworkAccessException) error.getCause()).errorCode; 346 if (code == ERROR_UNKNOWN || code == ERROR_HTTP_TIMEOUT) { 347 scheduleRetry(); 348 } else { 349 int failureCode; 350 if (code == ERROR_FORBIDDEN) { 351 failureCode = TelephonyManager.CallComposerException 352 .ERROR_AUTHENTICATION_FAILED; 353 } else { 354 failureCode = TelephonyManager.CallComposerException 355 .ERROR_UNKNOWN; 356 } 357 deliverFailure(failureCode); 358 } 359 } else { 360 deliverFailure(TelephonyManager.CallComposerException.ERROR_UNKNOWN); 361 } 362 } 363 } 364 scheduleRetry()365 private void scheduleRetry() { 366 mCallback.onRetryNeeded(false, DEFAULT_BACKOFF_MILLIS); 367 } 368 deliverFailure(int code)369 private void deliverFailure(int code) { 370 mCallback.onError(code); 371 } 372 makeUploadPart(String name, String contentType, String filename, byte[] data)373 private static Part makeUploadPart(String name, String contentType, String filename, 374 byte[] data) { 375 return new Part() { 376 @Override 377 public String getName() { 378 return name; 379 } 380 381 @Override 382 public String getContentType() { 383 return contentType; 384 } 385 386 @Override 387 public String getCharSet() { 388 return null; 389 } 390 391 @Override 392 public String getTransferEncoding() { 393 return null; 394 } 395 396 @Override 397 public void sendDispositionHeader(OutputStream out) throws IOException { 398 super.sendDispositionHeader(out); 399 if (filename != null) { 400 String fileNameSuffix = "; filename=\"" + filename + "\""; 401 out.write(fileNameSuffix.getBytes()); 402 } 403 } 404 405 @Override 406 protected void sendData(OutputStream out) throws IOException { 407 out.write(data); 408 } 409 410 @Override 411 protected long lengthOfData() throws IOException { 412 return data.length; 413 } 414 }; 415 } 416 417 private String sendActualImageUpload(Network network, String authHeader, ImageData image) { 418 Part transactionIdPart = makeUploadPart("tid", "text/plain", 419 null, image.getId().getBytes()); 420 Part imageDataPart = makeUploadPart("File", image.getMimeType(), 421 image.getId(), image.getImageBytes()); 422 423 MultipartEntity multipartEntity = 424 new MultipartEntity(new Part[] {transactionIdPart, imageDataPart}); 425 426 HttpURLConnection connection = prepareInitialPost(network, mUrl); 427 connection.setDoOutput(true); 428 connection.addRequestProperty("Authorization", authHeader); 429 connection.addRequestProperty("Content-Length", 430 String.valueOf(multipartEntity.getContentLength())); 431 connection.addRequestProperty("Content-Type", multipartEntity.getContentType().getValue()); 432 connection.addRequestProperty("Accept-Encoding", "*"); 433 434 try (OutputStream requestBodyOut = connection.getOutputStream()) { 435 multipartEntity.writeTo(requestBodyOut); 436 } catch (IOException e) { 437 logException("IOException making request to upload image: ", e); 438 throw new RuntimeException(e); 439 } 440 441 try { 442 int response = connection.getResponseCode(); 443 Log.i(TAG, "Received response code: " + response 444 + ", message=" + connection.getResponseMessage()); 445 if (response == 401 || response == 403) { 446 deliverFailure(TelephonyManager.CallComposerException.ERROR_AUTHENTICATION_FAILED); 447 return null; 448 } 449 if (response == 503) { 450 // TODO: implement parsing of retry-after and schedule a retry with that time 451 scheduleRetry(); 452 return null; 453 } 454 if (response != 200) { 455 scheduleRetry(); 456 return null; 457 } 458 String responseBody = readResponseBody(connection); 459 String parsedUrl = parseImageUploadResponseXmlForUrl(responseBody); 460 Log.i(TAG, "Parsed URL as upload result: " + parsedUrl); 461 return parsedUrl; 462 } catch (IOException e) { 463 logException("IOException getting response to image upload: ", e); 464 deliverFailure(TelephonyManager.CallComposerException.ERROR_UNKNOWN); 465 return null; 466 } 467 } 468 469 private static String parseImageUploadResponseXmlForUrl(String xmlData) { 470 NamespaceContext ns = new NamespaceContext() { 471 public String getNamespaceURI(String prefix) { 472 return "urn:gsma:params:xml:ns:rcs:rcs:fthttp"; 473 } 474 475 public String getPrefix(String uri) { 476 throw new UnsupportedOperationException(); 477 } 478 479 public Iterator getPrefixes(String uri) { 480 throw new UnsupportedOperationException(); 481 } 482 }; 483 484 XPath xPath = XPathFactory.newInstance().newXPath(); 485 xPath.setNamespaceContext(ns); 486 StringReader reader = new StringReader(xmlData); 487 try { 488 return (String) xPath.evaluate("/a:file/a:file-info[@type='file']/a:data/@url", 489 new InputSource(reader), XPathConstants.STRING); 490 } catch (XPathExpressionException e) { 491 logException("Error parsing response XML:", e); 492 return null; 493 } 494 } 495 496 private static String readResponseBody(HttpURLConnection connection) { 497 Charset charset = MediaType.parse(connection.getContentType()) 498 .charset().or(Charset.defaultCharset()); 499 StringBuilder sb = new StringBuilder(); 500 try (InputStream inputStream = connection.getInputStream()) { 501 String outLine; 502 BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, charset)); 503 while ((outLine = reader.readLine()) != null) { 504 sb.append(outLine); 505 } 506 } catch (IOException e) { 507 logException("IOException reading request body: ", e); 508 return null; 509 } 510 return sb.toString(); 511 } 512 513 private String getUserAgent() { 514 String carrierName = mContext.getSystemService(TelephonyManager.class) 515 .createForSubscriptionId(mSubscriptionId) 516 .getSimOperatorName(); 517 String buildId = Build.ID; 518 String buildDate = DateTimeFormatter.ofPattern("yyyy-MM-dd") 519 .withZone(ZoneId.systemDefault()) 520 .format(Instant.ofEpochMilli(Build.TIME)); 521 String buildVersion = Build.VERSION.RELEASE_OR_CODENAME; 522 String deviceName = Build.DEVICE; 523 return String.format("%s %s %s %s %s %s %s", 524 carrierName, buildId, buildDate, "Android", buildVersion, 525 deviceName, THREE_GPP_GBA); 526 527 } 528 529 private static void logException(String message, Throwable e) { 530 StringWriter log = new StringWriter(); 531 log.append(message); 532 log.append(":\n"); 533 log.append(e.getMessage()); 534 PrintWriter pw = new PrintWriter(log); 535 e.printStackTrace(pw); 536 Log.e(TAG, log.toString()); 537 } 538 } 539