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