1 /*
2  * Copyright (C) 2014 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.mms.service;
18 
19 import android.content.Context;
20 import android.net.ConnectivityManager;
21 import android.net.LinkProperties;
22 import android.net.Network;
23 import android.os.Bundle;
24 import android.telephony.CarrierConfigManager;
25 import android.telephony.SmsManager;
26 import android.telephony.SubscriptionManager;
27 import android.telephony.TelephonyManager;
28 import android.text.TextUtils;
29 import android.util.Base64;
30 import android.util.Log;
31 
32 import com.android.internal.annotations.VisibleForTesting;
33 import com.android.mms.service.exception.MmsHttpException;
34 
35 import java.io.BufferedInputStream;
36 import java.io.BufferedOutputStream;
37 import java.io.ByteArrayOutputStream;
38 import java.io.IOException;
39 import java.io.InputStream;
40 import java.io.OutputStream;
41 import java.io.UnsupportedEncodingException;
42 import java.net.HttpURLConnection;
43 import java.net.Inet4Address;
44 import java.net.InetAddress;
45 import java.net.InetSocketAddress;
46 import java.net.MalformedURLException;
47 import java.net.ProtocolException;
48 import java.net.Proxy;
49 import java.net.URL;
50 import java.util.List;
51 import java.util.Locale;
52 import java.util.Map;
53 import java.util.regex.Matcher;
54 import java.util.regex.Pattern;
55 
56 /**
57  * MMS HTTP client for sending and downloading MMS messages
58  */
59 public class MmsHttpClient {
60     public static final String METHOD_POST = "POST";
61     public static final String METHOD_GET = "GET";
62 
63     private static final String HEADER_CONTENT_TYPE = "Content-Type";
64     private static final String HEADER_ACCEPT = "Accept";
65     private static final String HEADER_ACCEPT_LANGUAGE = "Accept-Language";
66     private static final String HEADER_USER_AGENT = "User-Agent";
67     private static final String HEADER_CONNECTION = "Connection";
68 
69     // The "Accept" header value
70     private static final String HEADER_VALUE_ACCEPT =
71             "*/*, application/vnd.wap.mms-message, application/vnd.wap.sic";
72     // The "Content-Type" header value
73     private static final String HEADER_VALUE_CONTENT_TYPE_WITH_CHARSET =
74             "application/vnd.wap.mms-message; charset=utf-8";
75     private static final String HEADER_VALUE_CONTENT_TYPE_WITHOUT_CHARSET =
76             "application/vnd.wap.mms-message";
77     private static final String HEADER_CONNECTION_CLOSE = "close";
78 
79     // Used for configs that specify a UA_PROF_URL, but not a name
80     private static final String UA_PROF_TAG_NAME_DEFAULT = "x-wap-profile";
81 
82     private static final int IPV4_WAIT_ATTEMPTS = 15;
83     private static final long IPV4_WAIT_DELAY_MS = 1000; // 1 seconds
84 
85     private final Context mContext;
86     private final Network mNetwork;
87     private final ConnectivityManager mConnectivityManager;
88 
89     /**
90      * Constructor
91      *
92      * @param context The Context object
93      * @param network The Network for creating an OKHttp client
94      */
MmsHttpClient(Context context, Network network, ConnectivityManager connectivityManager)95     public MmsHttpClient(Context context, Network network,
96             ConnectivityManager connectivityManager) {
97         mContext = context;
98         // Mms server is on a carrier private network so it may not be resolvable using 3rd party
99         // private dns
100         mNetwork = network.getPrivateDnsBypassingCopy();
101         mConnectivityManager = connectivityManager;
102     }
103 
104     /**
105      * Execute an MMS HTTP request, either a POST (sending) or a GET (downloading)
106      *
107      * @param urlString  The request URL, for sending it is usually the MMSC, and for downloading
108      *                   it is the message URL
109      * @param pdu        For POST (sending) only, the PDU to send
110      * @param method     HTTP method, POST for sending and GET for downloading
111      * @param isProxySet Is there a proxy for the MMSC
112      * @param proxyHost  The proxy host
113      * @param proxyPort  The proxy port
114      * @param mmsConfig  The MMS config to use
115      * @param subId      The subscription ID used to get line number, etc.
116      * @param requestId  The request ID for logging
117      * @return The HTTP response body
118      * @throws MmsHttpException For any failures
119      */
execute(String urlString, byte[] pdu, String method, boolean isProxySet, String proxyHost, int proxyPort, Bundle mmsConfig, int subId, String requestId)120     public byte[] execute(String urlString, byte[] pdu, String method, boolean isProxySet,
121             String proxyHost, int proxyPort, Bundle mmsConfig, int subId, String requestId)
122             throws MmsHttpException {
123         LogUtil.d(requestId, "HTTP: " + method + " " + redactUrlForNonVerbose(urlString)
124                 + (isProxySet ? (", proxy=" + proxyHost + ":" + proxyPort) : "")
125                 + ", PDU size=" + (pdu != null ? pdu.length : 0));
126         checkMethod(method);
127         HttpURLConnection connection = null;
128         try {
129             Proxy proxy = Proxy.NO_PROXY;
130             if (isProxySet) {
131                 proxy = new Proxy(Proxy.Type.HTTP,
132                         new InetSocketAddress(mNetwork.getByName(proxyHost), proxyPort));
133             }
134             final URL url = new URL(urlString);
135             maybeWaitForIpv4(requestId, url);
136             // Now get the connection
137             connection = (HttpURLConnection) mNetwork.openConnection(url, proxy);
138             connection.setDoInput(true);
139             connection.setConnectTimeout(
140                     mmsConfig.getInt(SmsManager.MMS_CONFIG_HTTP_SOCKET_TIMEOUT));
141             connection.setReadTimeout(
142                     mmsConfig.getInt(SmsManager.MMS_CONFIG_HTTP_SOCKET_TIMEOUT));
143             // ------- COMMON HEADERS ---------
144             // Header: Accept
145             connection.setRequestProperty(HEADER_ACCEPT, HEADER_VALUE_ACCEPT);
146             // Header: Accept-Language
147             connection.setRequestProperty(
148                     HEADER_ACCEPT_LANGUAGE, getCurrentAcceptLanguage(Locale.getDefault()));
149             // Header: User-Agent
150             final String userAgent = mmsConfig.getString(SmsManager.MMS_CONFIG_USER_AGENT);
151             LogUtil.i(requestId, "HTTP: User-Agent=" + userAgent);
152             connection.setRequestProperty(HEADER_USER_AGENT, userAgent);
153             // Header: x-wap-profile
154             String uaProfUrlTagName =
155                     mmsConfig.getString(SmsManager.MMS_CONFIG_UA_PROF_TAG_NAME);
156             final String uaProfUrl = mmsConfig.getString(SmsManager.MMS_CONFIG_UA_PROF_URL);
157 
158             if (!TextUtils.isEmpty(uaProfUrl)) {
159                 if (TextUtils.isEmpty(uaProfUrlTagName)) {
160                     uaProfUrlTagName = UA_PROF_TAG_NAME_DEFAULT;
161                 }
162 
163                 LogUtil.i(requestId,
164                         "HTTP: UaProfUrl=" + uaProfUrl + ", UaProfUrlTagName=" + uaProfUrlTagName);
165 
166                 connection.setRequestProperty(uaProfUrlTagName, uaProfUrl);
167             }
168             // Header: Connection: close (if needed)
169             // Some carriers require that the HTTP connection's socket is closed
170             // after an MMS request/response is complete. In these cases keep alive
171             // is disabled. See https://tools.ietf.org/html/rfc7230#section-6.6
172             if (mmsConfig.getBoolean(CarrierConfigManager.KEY_MMS_CLOSE_CONNECTION_BOOL, false)) {
173                 LogUtil.i(requestId, "HTTP: Connection close after request");
174                 connection.setRequestProperty(HEADER_CONNECTION, HEADER_CONNECTION_CLOSE);
175             }
176             // Add extra headers specified by mms_config.xml's httpparams
177             addExtraHeaders(connection, mmsConfig, subId);
178             // Different stuff for GET and POST
179             if (METHOD_POST.equals(method)) {
180                 if (pdu == null || pdu.length < 1) {
181                     LogUtil.e(requestId, "HTTP: empty pdu");
182                     throw new MmsHttpException(0/*statusCode*/, "Sending empty PDU");
183                 }
184                 connection.setDoOutput(true);
185                 connection.setRequestMethod(METHOD_POST);
186                 if (mmsConfig.getBoolean(SmsManager.MMS_CONFIG_SUPPORT_HTTP_CHARSET_HEADER)) {
187                     connection.setRequestProperty(HEADER_CONTENT_TYPE,
188                             HEADER_VALUE_CONTENT_TYPE_WITH_CHARSET);
189                 } else {
190                     connection.setRequestProperty(HEADER_CONTENT_TYPE,
191                             HEADER_VALUE_CONTENT_TYPE_WITHOUT_CHARSET);
192                 }
193                 if (LogUtil.isLoggable(Log.VERBOSE)) {
194                     logHttpHeaders(connection.getRequestProperties(), requestId);
195                 }
196                 connection.setFixedLengthStreamingMode(pdu.length);
197                 // Sending request body
198                 final OutputStream out =
199                         new BufferedOutputStream(connection.getOutputStream());
200                 out.write(pdu);
201                 out.flush();
202                 out.close();
203             } else if (METHOD_GET.equals(method)) {
204                 if (LogUtil.isLoggable(Log.VERBOSE)) {
205                     logHttpHeaders(connection.getRequestProperties(), requestId);
206                 }
207                 connection.setRequestMethod(METHOD_GET);
208             }
209             // Get response
210             final int responseCode = connection.getResponseCode();
211             final String responseMessage = connection.getResponseMessage();
212             LogUtil.d(requestId, "HTTP: " + responseCode + " " + responseMessage);
213             if (LogUtil.isLoggable(Log.VERBOSE)) {
214                 logHttpHeaders(connection.getHeaderFields(), requestId);
215             }
216             if (responseCode / 100 != 2) {
217                 throw new MmsHttpException(responseCode, responseMessage);
218             }
219             final InputStream in = new BufferedInputStream(connection.getInputStream());
220             final ByteArrayOutputStream byteOut = new ByteArrayOutputStream();
221             final byte[] buf = new byte[4096];
222             int count = 0;
223             while ((count = in.read(buf)) > 0) {
224                 byteOut.write(buf, 0, count);
225             }
226             in.close();
227             final byte[] responseBody = byteOut.toByteArray();
228             LogUtil.d(requestId, "HTTP: response size="
229                     + (responseBody != null ? responseBody.length : 0));
230             return responseBody;
231         } catch (MalformedURLException e) {
232             final String redactedUrl = redactUrlForNonVerbose(urlString);
233             LogUtil.e(requestId, "HTTP: invalid URL " + redactedUrl, e);
234             throw new MmsHttpException(0/*statusCode*/, "Invalid URL " + redactedUrl, e);
235         } catch (ProtocolException e) {
236             final String redactedUrl = redactUrlForNonVerbose(urlString);
237             LogUtil.e(requestId, "HTTP: invalid URL protocol " + redactedUrl, e);
238             throw new MmsHttpException(0/*statusCode*/, "Invalid URL protocol " + redactedUrl, e);
239         } catch (IOException e) {
240             LogUtil.e(requestId, "HTTP: IO failure", e);
241             throw new MmsHttpException(0/*statusCode*/, e);
242         } finally {
243             if (connection != null) {
244                 connection.disconnect();
245             }
246         }
247     }
248 
maybeWaitForIpv4(final String requestId, final URL url)249     private void maybeWaitForIpv4(final String requestId, final URL url) {
250         // If it's a literal IPv4 address and we're on an IPv6-only network,
251         // wait until IPv4 is available.
252         Inet4Address ipv4Literal = null;
253         try {
254             ipv4Literal = (Inet4Address) InetAddress.parseNumericAddress(url.getHost());
255         } catch (IllegalArgumentException | ClassCastException e) {
256             // Ignore
257         }
258         if (ipv4Literal == null) {
259             // Not an IPv4 address.
260             return;
261         }
262         for (int i = 0; i < IPV4_WAIT_ATTEMPTS; i++) {
263             final LinkProperties lp = mConnectivityManager.getLinkProperties(mNetwork);
264             if (lp != null) {
265                 if (!lp.isReachable(ipv4Literal)) {
266                     LogUtil.w(requestId, "HTTP: IPv4 not yet provisioned");
267                     try {
268                         Thread.sleep(IPV4_WAIT_DELAY_MS);
269                     } catch (InterruptedException e) {
270                         // Ignore
271                     }
272                 } else {
273                     LogUtil.i(requestId, "HTTP: IPv4 provisioned");
274                     break;
275                 }
276             } else {
277                 LogUtil.w(requestId, "HTTP: network disconnected, skip ipv4 check");
278                 break;
279             }
280         }
281     }
282 
logHttpHeaders(Map<String, List<String>> headers, String requestId)283     private static void logHttpHeaders(Map<String, List<String>> headers, String requestId) {
284         final StringBuilder sb = new StringBuilder();
285         if (headers != null) {
286             for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
287                 final String key = entry.getKey();
288                 final List<String> values = entry.getValue();
289                 if (values != null) {
290                     for (String value : values) {
291                         sb.append(key).append('=').append(value).append('\n');
292                     }
293                 }
294             }
295             LogUtil.v(requestId, "HTTP: headers\n" + sb.toString());
296         }
297     }
298 
checkMethod(String method)299     private static void checkMethod(String method) throws MmsHttpException {
300         if (!METHOD_GET.equals(method) && !METHOD_POST.equals(method)) {
301             throw new MmsHttpException(0/*statusCode*/, "Invalid method " + method);
302         }
303     }
304 
305     private static final String ACCEPT_LANG_FOR_US_LOCALE = "en-US";
306 
307     /**
308      * Return the Accept-Language header.  Use the current locale plus
309      * US if we are in a different locale than US.
310      * This code copied from the browser's WebSettings.java
311      *
312      * @return Current AcceptLanguage String.
313      */
getCurrentAcceptLanguage(Locale locale)314     public static String getCurrentAcceptLanguage(Locale locale) {
315         final StringBuilder buffer = new StringBuilder();
316         addLocaleToHttpAcceptLanguage(buffer, locale);
317 
318         if (!Locale.US.equals(locale)) {
319             if (buffer.length() > 0) {
320                 buffer.append(", ");
321             }
322             buffer.append(ACCEPT_LANG_FOR_US_LOCALE);
323         }
324 
325         return buffer.toString();
326     }
327 
328     /**
329      * Convert obsolete language codes, including Hebrew/Indonesian/Yiddish,
330      * to new standard.
331      */
convertObsoleteLanguageCodeToNew(String langCode)332     private static String convertObsoleteLanguageCodeToNew(String langCode) {
333         if (langCode == null) {
334             return null;
335         }
336         if ("iw".equals(langCode)) {
337             // Hebrew
338             return "he";
339         } else if ("in".equals(langCode)) {
340             // Indonesian
341             return "id";
342         } else if ("ji".equals(langCode)) {
343             // Yiddish
344             return "yi";
345         }
346         return langCode;
347     }
348 
addLocaleToHttpAcceptLanguage(StringBuilder builder, Locale locale)349     private static void addLocaleToHttpAcceptLanguage(StringBuilder builder, Locale locale) {
350         final String language = convertObsoleteLanguageCodeToNew(locale.getLanguage());
351         if (language != null) {
352             builder.append(language);
353             final String country = locale.getCountry();
354             if (country != null) {
355                 builder.append("-");
356                 builder.append(country);
357             }
358         }
359     }
360 
361     /**
362      * Add extra HTTP headers from mms_config.xml's httpParams, which is a list of key/value
363      * pairs separated by "|". Each key/value pair is separated by ":". Value may contain
364      * macros like "##LINE1##" or "##NAI##" which is resolved with methods in this class
365      *
366      * @param connection The HttpURLConnection that we add headers to
367      * @param mmsConfig  The MmsConfig object
368      * @param subId      The subscription ID used to get line number, etc.
369      */
addExtraHeaders(HttpURLConnection connection, Bundle mmsConfig, int subId)370     private void addExtraHeaders(HttpURLConnection connection, Bundle mmsConfig, int subId) {
371         final String extraHttpParams = mmsConfig.getString(SmsManager.MMS_CONFIG_HTTP_PARAMS);
372         if (!TextUtils.isEmpty(extraHttpParams)) {
373             // Parse the parameter list
374             String paramList[] = extraHttpParams.split("\\|");
375             for (String paramPair : paramList) {
376                 String splitPair[] = paramPair.split(":", 2);
377                 if (splitPair.length == 2) {
378                     final String name = splitPair[0].trim();
379                     final String value =
380                             resolveMacro(mContext, splitPair[1].trim(), mmsConfig, subId);
381                     if (!TextUtils.isEmpty(name) && !TextUtils.isEmpty(value)) {
382                         // Add the header if the param is valid
383                         connection.setRequestProperty(name, value);
384                     }
385                 }
386             }
387         }
388     }
389 
390     private static final Pattern MACRO_P = Pattern.compile("##(\\S+)##");
391 
392     /**
393      * Resolve the macro in HTTP param value text
394      * For example, "something##LINE1##something" is resolved to "something9139531419something"
395      *
396      * @param value The HTTP param value possibly containing macros
397      * @param subId The subscription ID used to get line number, etc.
398      * @return The HTTP param with macros resolved to real value
399      */
resolveMacro(Context context, String value, Bundle mmsConfig, int subId)400     private static String resolveMacro(Context context, String value, Bundle mmsConfig, int subId) {
401         if (TextUtils.isEmpty(value)) {
402             return value;
403         }
404         final Matcher matcher = MACRO_P.matcher(value);
405         int nextStart = 0;
406         StringBuilder replaced = null;
407         while (matcher.find()) {
408             if (replaced == null) {
409                 replaced = new StringBuilder();
410             }
411             final int matchedStart = matcher.start();
412             if (matchedStart > nextStart) {
413                 replaced.append(value.substring(nextStart, matchedStart));
414             }
415             final String macro = matcher.group(1);
416             final String macroValue = getMacroValue(context, macro, mmsConfig, subId);
417             if (macroValue != null) {
418                 replaced.append(macroValue);
419             }
420             nextStart = matcher.end();
421         }
422         if (replaced != null && nextStart < value.length()) {
423             replaced.append(value.substring(nextStart));
424         }
425         return replaced == null ? value : replaced.toString();
426     }
427 
428     /**
429      * Redact the URL for non-VERBOSE logging. Replace url with only the host part and the length
430      * of the input URL string.
431      */
redactUrlForNonVerbose(String urlString)432     public static String redactUrlForNonVerbose(String urlString) {
433         if (LogUtil.isLoggable(Log.VERBOSE)) {
434             // Don't redact for VERBOSE level logging
435             return urlString;
436         }
437         if (TextUtils.isEmpty(urlString)) {
438             return urlString;
439         }
440         String protocol = "http";
441         String host = "";
442         try {
443             final URL url = new URL(urlString);
444             protocol = url.getProtocol();
445             host = url.getHost();
446         } catch (MalformedURLException e) {
447             // Ignore
448         }
449         // Print "http://host[length]"
450         final StringBuilder sb = new StringBuilder();
451         sb.append(protocol).append("://").append(host)
452                 .append("[").append(urlString.length()).append("]");
453         return sb.toString();
454     }
455 
getPhoneNumberForMacroLine1(TelephonyManager telephonyManager, Context context, int subId)456     private static String getPhoneNumberForMacroLine1(TelephonyManager telephonyManager,
457         Context context, int subId) {
458         String phoneNo = telephonyManager.getLine1Number();
459         if (TextUtils.isEmpty(phoneNo)) {
460             SubscriptionManager subscriptionManager = context.getSystemService(
461                 SubscriptionManager.class);
462             if (subscriptionManager != null) {
463                 phoneNo = subscriptionManager.getPhoneNumber(subId);
464             } else {
465                 LogUtil.e("subscriptionManager is null");
466             }
467         }
468         return phoneNo;
469     }
470 
471     /*
472      * Macro names
473      */
474     // The raw phone number from TelephonyManager.getLine1Number
475     private static final String MACRO_LINE1 = "LINE1";
476     // The phone number without country code
477     private static final String MACRO_LINE1NOCOUNTRYCODE = "LINE1NOCOUNTRYCODE";
478     // NAI (Network Access Identifier), used by Sprint for authentication
479     private static final String MACRO_NAI = "NAI";
480 
481     /**
482      * Return the HTTP param macro value.
483      * Example: "LINE1" returns the phone number, etc.
484      *
485      * @param macro     The macro name
486      * @param mmsConfig The MMS config which contains NAI suffix.
487      * @param subId     The subscription ID used to get line number, etc.
488      * @return The value of the defined macro
489      */
490     @VisibleForTesting
getMacroValue(Context context, String macro, Bundle mmsConfig, int subId)491     public static String getMacroValue(Context context, String macro, Bundle mmsConfig,
492         int subId) {
493         final TelephonyManager telephonyManager = ((TelephonyManager) context.getSystemService(
494             Context.TELEPHONY_SERVICE)).createForSubscriptionId(subId);
495         if (MACRO_LINE1.equals(macro)) {
496             return getPhoneNumberForMacroLine1(telephonyManager, context, subId);
497         } else if (MACRO_LINE1NOCOUNTRYCODE.equals(macro)) {
498             return PhoneUtils.getNationalNumber(telephonyManager,
499                 getPhoneNumberForMacroLine1(telephonyManager, context, subId));
500         } else if (MACRO_NAI.equals(macro)) {
501             return getNai(telephonyManager, mmsConfig);
502         }
503         LogUtil.e("Invalid macro " + macro);
504         return null;
505     }
506 
507     /**
508      * Returns the NAI (Network Access Identifier) from SystemProperties for the given subscription
509      * ID.
510      */
getNai(TelephonyManager telephonyManager, Bundle mmsConfig)511     private static String getNai(TelephonyManager telephonyManager, Bundle mmsConfig) {
512         String nai = telephonyManager.getNai();
513         if (LogUtil.isLoggable(Log.VERBOSE)) {
514             LogUtil.v("getNai: nai=" + nai);
515         }
516 
517         if (!TextUtils.isEmpty(nai)) {
518             String naiSuffix = mmsConfig.getString(SmsManager.MMS_CONFIG_NAI_SUFFIX);
519             if (!TextUtils.isEmpty(naiSuffix)) {
520                 nai = nai + naiSuffix;
521             }
522             byte[] encoded = null;
523             try {
524                 encoded = Base64.encode(nai.getBytes("UTF-8"), Base64.NO_WRAP);
525             } catch (UnsupportedEncodingException e) {
526                 encoded = Base64.encode(nai.getBytes(), Base64.NO_WRAP);
527             }
528             try {
529                 nai = new String(encoded, "UTF-8");
530             } catch (UnsupportedEncodingException e) {
531                 nai = new String(encoded);
532             }
533         }
534         return nai;
535     }
536 }
537