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