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