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