1 package com.android.server.wifi.configparse; 2 3 import android.util.Log; 4 5 import com.android.server.wifi.hotspot2.Utils; 6 7 import java.io.IOException; 8 import java.io.LineNumberReader; 9 import java.nio.charset.Charset; 10 import java.nio.charset.StandardCharsets; 11 import java.util.ArrayList; 12 import java.util.Arrays; 13 import java.util.HashMap; 14 import java.util.List; 15 import java.util.Map; 16 import java.util.concurrent.atomic.AtomicBoolean; 17 18 public class MIMEContainer { 19 private static final String Type = "Content-Type"; 20 private static final String Encoding = "Content-Transfer-Encoding"; 21 22 private static final String Boundary = "boundary="; 23 private static final String CharsetTag = "charset="; 24 25 private final boolean mLast; 26 private final List<MIMEContainer> mMimeContainers; 27 private final String mText; 28 29 private final boolean mMixed; 30 private final boolean mBase64; 31 private final Charset mCharset; 32 private final String mContentType; 33 34 /** 35 * Parse nested MIME content 36 * @param in A reader to read MIME data from; Note that the charset should be ISO-8859-1 to 37 * ensure transparent octet to character mapping. This is because the content will 38 * be re-encoded using the correct charset once it is discovered. 39 * @param boundary A boundary string for the MIME section that this container is in. 40 * Pass null for the top level object. 41 * @throws java.io.IOException 42 */ 43 public MIMEContainer(LineNumberReader in, String boundary) throws IOException { 44 Map<String,List<String>> headers = parseHeader(in); 45 46 List<String> type = headers.get(Type); 47 if (type == null || type.isEmpty()) { 48 throw new IOException("Missing " + Type + " @ " + in.getLineNumber()); 49 } 50 51 boolean multiPart = false; 52 boolean mixed = false; 53 String subBoundary = null; 54 Charset charset = StandardCharsets.ISO_8859_1; 55 56 mContentType = type.get(0); 57 58 if (mContentType.startsWith("multipart/")) { 59 multiPart = true; 60 61 for (String attribute : type) { 62 if (attribute.startsWith(Boundary)) { 63 subBoundary = Utils.unquote(attribute.substring(Boundary.length())); 64 } 65 } 66 67 if (mContentType.endsWith("/mixed")) { 68 mixed = true; 69 } 70 } 71 else if (mContentType.startsWith("text/")) { 72 for (String attribute : type) { 73 if (attribute.startsWith(CharsetTag)) { 74 charset = Charset.forName(attribute.substring(CharsetTag.length())); 75 } 76 } 77 } 78 79 mMixed = mixed; 80 mCharset = charset; 81 82 if (multiPart && subBoundary != null) { 83 for (;;) { 84 String line = in.readLine(); 85 if (line == null) { 86 throw new IOException("Unexpected EOF before first boundary @ " + 87 in.getLineNumber()); 88 } 89 if (line.startsWith("--") && line.length() == subBoundary.length() + 2 && 90 line.regionMatches(2, subBoundary, 0, subBoundary.length())) { 91 break; 92 } 93 } 94 95 mMimeContainers = new ArrayList<>(); 96 for (;;) { 97 MIMEContainer container = new MIMEContainer(in, subBoundary); 98 mMimeContainers.add(container); 99 if (container.isLast()) { 100 break; 101 } 102 } 103 } 104 else { 105 mMimeContainers = null; 106 } 107 108 List<String> encoding = headers.get(Encoding); 109 boolean quoted = false; 110 boolean base64 = false; 111 if (encoding != null) { 112 for (String text : encoding) { 113 if (text.equalsIgnoreCase("quoted-printable")) { 114 quoted = true; 115 break; 116 } 117 else if (text.equalsIgnoreCase("base64")) { 118 base64 = true; 119 break; 120 } 121 } 122 } 123 mBase64 = base64; 124 125 Log.d(Utils.hs2LogTag(getClass()), 126 String.format("%s MIME container, boundary '%s', type '%s', encoding %s", 127 multiPart ? "multipart" : "plain", boundary, mContentType, encoding)); 128 129 AtomicBoolean eof = new AtomicBoolean(); 130 mText = recode(getBody(in, boundary, quoted, eof), charset); 131 mLast = eof.get(); 132 } 133 134 public List<MIMEContainer> getMimeContainers() { 135 return mMimeContainers; 136 } 137 138 public String getText() { 139 return mText; 140 } 141 142 public boolean isMixed() { 143 return mMixed; 144 } 145 146 public boolean isBase64() { 147 return mBase64; 148 } 149 150 public String getContentType() { 151 return mContentType; 152 } 153 154 private boolean isLast() { 155 return mLast; 156 } 157 158 private void toString(StringBuilder sb, int nesting) { 159 char[] indent = new char[nesting*4]; 160 Arrays.fill(indent, ' '); 161 if (mBase64) { 162 sb.append("base64, type ").append(mContentType).append('\n'); 163 } 164 else if (mMimeContainers != null) { 165 sb.append(indent).append("multipart/").append((mMixed ? "mixed" : "other" )).append('\n'); 166 } 167 else { 168 sb.append(indent).append( 169 String.format("%s, type %s", 170 mCharset, 171 mContentType) 172 ).append('\n'); 173 } 174 175 if (mMimeContainers != null) { 176 for (MIMEContainer mimeContainer : mMimeContainers) { 177 mimeContainer.toString(sb, nesting + 1); 178 } 179 } 180 sb.append(indent).append("Text: "); 181 if (mText.length() < 100000) { 182 sb.append("'").append(mText).append("'\n"); 183 } 184 else { 185 sb.append(mText.length()).append(" chars\n"); 186 } 187 } 188 189 @Override 190 public String toString() { 191 StringBuilder sb = new StringBuilder(); 192 toString(sb, 0); 193 return sb.toString(); 194 } 195 196 private static Map<String,List<String>> parseHeader(LineNumberReader in) throws IOException { 197 198 StringBuilder value = null; 199 String header = null; 200 201 Map<String,List<String>> headers = new HashMap<>(); 202 203 for (;;) { 204 String line = in.readLine(); 205 if ( line == null ) { 206 throw new IOException("Missing body @ " + in.getLineNumber()); 207 } 208 else if (line.length() == 0) { 209 break; 210 } 211 212 if (line.charAt(0) <= ' ') { 213 if (value == null) { 214 throw new IOException("Illegal blank prefix in header line '" + line + "' @ " + in.getLineNumber()); 215 } 216 value.append(' ').append(line.trim()); 217 continue; 218 } 219 220 int nameEnd = line.indexOf(':'); 221 if (nameEnd < 0) { 222 throw new IOException("Bad header line: '" + line + "' @ " + in.getLineNumber()); 223 } 224 225 if (header != null) { 226 String[] values = value.toString().split(";"); 227 List<String> valueList = new ArrayList<>(values.length); 228 for (String segment : values) { 229 valueList.add(segment.trim()); 230 } 231 headers.put(header, valueList); 232 //System.out.println("Header '" + header + "' = " + valueList); 233 } 234 235 header = line.substring(0, nameEnd); 236 value = new StringBuilder(); 237 value.append(line.substring(nameEnd+1).trim()); 238 } 239 240 if (header != null) { 241 String[] values = value.toString().split(";"); 242 List<String> valueList = new ArrayList<>(values.length); 243 for (String segment : values) { 244 valueList.add(segment.trim()); 245 } 246 headers.put(header, valueList); 247 //System.out.println("Header '" + header + "' = " + valueList); 248 } 249 250 return headers; 251 } 252 253 private static String getBody(LineNumberReader in, String boundary, boolean quoted, AtomicBoolean eof) 254 throws IOException { 255 256 StringBuilder text = new StringBuilder(); 257 for (;;) { 258 String line = in.readLine(); 259 if (line == null) { 260 if (boundary != null) { 261 throw new IOException("Unexpected EOF file in body @ " + in.getLineNumber()); 262 } 263 else { 264 return text.toString(); 265 } 266 } 267 Boolean end = boundaryCheck(line, boundary); 268 if (end != null) { 269 eof.set(end); 270 //System.out.println("Boundary " + boundary + ": " + end); 271 return text.toString(); 272 } 273 274 if (quoted) { 275 if (line.endsWith("=")) { 276 text.append(unescape(line.substring(line.length() - 1), in.getLineNumber())); 277 } 278 else { 279 text.append(unescape(line, in.getLineNumber())); 280 } 281 } 282 else { 283 text.append(line); 284 } 285 } 286 } 287 288 private static String recode(String s, Charset charset) { 289 if (charset.equals(StandardCharsets.ISO_8859_1) || charset.equals(StandardCharsets.US_ASCII)) { 290 return s; 291 } 292 293 byte[] octets = s.getBytes(StandardCharsets.ISO_8859_1); 294 return new String(octets, charset); 295 } 296 297 private static Boolean boundaryCheck(String line, String boundary) { 298 if (line.startsWith("--") && line.regionMatches(2, boundary, 0, boundary.length())) { 299 if (line.length() == boundary.length() + 2) { 300 return Boolean.FALSE; 301 } 302 else if (line.length() == boundary.length() + 4 && line.endsWith("--") ) { 303 return Boolean.TRUE; 304 } 305 } 306 return null; 307 } 308 309 private static String unescape(String text, int line) throws IOException { 310 StringBuilder sb = new StringBuilder(); 311 for (int n = 0; n < text.length(); n++) { 312 char ch = text.charAt(n); 313 if (ch > 127) { 314 throw new IOException("Bad codepoint " + (int)ch + " in quoted printable @ " + line); 315 } 316 if (ch == '=' && n < text.length() - 2) { 317 int h1 = fromStrictHex(text.charAt(n+1)); 318 int h2 = fromStrictHex(text.charAt(n+2)); 319 if (h1 >= 0 && h2 >= 0) { 320 sb.append((char)((h1 << 4) | h2)); 321 n += 2; 322 } 323 else { 324 sb.append(ch); 325 } 326 } 327 else { 328 sb.append(ch); 329 } 330 } 331 return sb.toString(); 332 } 333 334 private static int fromStrictHex(char ch) { 335 if (ch >= '0' && ch <= '9') { 336 return ch - '0'; 337 } 338 else if (ch >= 'A' && ch <= 'F') { 339 return ch - 'A' + 10; 340 } 341 else { 342 return -1; 343 } 344 } 345 } 346