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