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