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