• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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