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