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