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