1 /* 2 * Copyright (C) 2013 Samsung System LSI 3 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * you may not use this file except in compliance with the License. 5 * You may obtain a copy of the License at 6 * 7 * http://www.apache.org/licenses/LICENSE-2.0 8 * 9 * Unless required by applicable law or agreed to in writing, software 10 * distributed under the License is distributed on an "AS IS" BASIS, 11 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 * See the License for the specific language governing permissions and 13 * limitations under the License. 14 */ 15 package com.android.bluetooth.map; 16 17 import java.io.UnsupportedEncodingException; 18 import java.nio.charset.Charset; 19 import java.text.SimpleDateFormat; 20 import java.util.ArrayList; 21 import java.util.Arrays; 22 import java.util.Date; 23 import java.util.Locale; 24 import java.util.UUID; 25 26 import android.text.util.Rfc822Token; 27 import android.text.util.Rfc822Tokenizer; 28 import android.util.Base64; 29 import android.util.Log; 30 31 public class BluetoothMapbMessageMmsEmail extends BluetoothMapbMessage { 32 33 public static class MimePart { 34 public long _id = INVALID_VALUE; /* The _id from the content provider, can be used to sort the parts if needed */ 35 public String contentType = null; /* The mime type, e.g. text/plain */ 36 public String contentId = null; 37 public String contentLocation = null; 38 public String contentDisposition = null; 39 public String partName = null; /* e.g. text_1.txt*/ 40 public String charsetName = null; /* This seems to be a number e.g. 106 for UTF-8 CharacterSets 41 holds a method for the mapping. */ 42 public String fileName = null; /* Do not seem to be used */ 43 public byte[] data = null; /* The raw un-encoded data e.g. the raw jpeg data or the text.getBytes("utf-8") */ 44 45 46 47 public void encode(StringBuilder sb, String boundaryTag, boolean last) throws UnsupportedEncodingException { 48 sb.append("--").append(boundaryTag).append("\r\n"); 49 if(contentType != null) 50 sb.append("Content-Type: ").append(contentType); 51 if(charsetName != null) 52 sb.append("; ").append("charset=\"").append(charsetName).append("\""); 53 sb.append("\r\n"); 54 if(contentLocation != null) 55 sb.append("Content-Location: ").append(contentLocation).append("\r\n"); 56 if(contentId != null) 57 sb.append("Content-ID: ").append(contentId).append("\r\n"); 58 if(contentDisposition != null) 59 sb.append("Content-Disposition: ").append(contentDisposition).append("\r\n"); 60 if(data != null) { 61 /* TODO: If errata 4176 is adopted in the current form (it is not in either 1.1 or 1.2), 62 the below is not allowed, Base64 should be used for text. */ 63 64 if(contentType != null && 65 (contentType.toUpperCase().contains("TEXT") || 66 contentType.toUpperCase().contains("SMIL") )) { 67 sb.append("Content-Transfer-Encoding: 8BIT\r\n\r\n"); // Add the header split empty line 68 sb.append(new String(data,"UTF-8")).append("\r\n"); 69 } 70 else { 71 sb.append("Content-Transfer-Encoding: Base64\r\n\r\n"); // Add the header split empty line 72 sb.append(Base64.encodeToString(data, Base64.DEFAULT)).append("\r\n"); 73 } 74 } 75 if(last) { 76 sb.append("--").append(boundaryTag).append("--").append("\r\n"); 77 } 78 } 79 80 public void encodePlainText(StringBuilder sb) throws UnsupportedEncodingException { 81 if(contentType != null && contentType.toUpperCase().contains("TEXT")) { 82 sb.append(new String(data,"UTF-8")).append("\r\n"); 83 } else if(contentType != null && contentType.toUpperCase().contains("/SMIL")) { 84 /* Skip the smil.xml, as no-one knows what it is. */ 85 } else { 86 /* Not a text part, just print the filename or part name if they exist. */ 87 if(partName != null) 88 sb.append("<").append(partName).append(">\r\n"); 89 else 90 sb.append("<").append("attachment").append(">\r\n"); 91 } 92 } 93 } 94 95 private long date = INVALID_VALUE; 96 private String subject = null; 97 private ArrayList<Rfc822Token> from = null; // Shall not be empty 98 private ArrayList<Rfc822Token> sender = null; // Shall not be empty 99 private ArrayList<Rfc822Token> to = null; // Shall not be empty 100 private ArrayList<Rfc822Token> cc = null; // Can be empty 101 private ArrayList<Rfc822Token> bcc = null; // Can be empty 102 private ArrayList<Rfc822Token> replyTo = null;// Can be empty 103 private String messageId = null; 104 private ArrayList<MimePart> parts = null; 105 private String contentType = null; 106 private String boundary = null; 107 private boolean textOnly = false; 108 private boolean includeAttachments; 109 private boolean hasHeaders = false; 110 private String encoding = null; 111 112 private String getBoundary() { 113 if(boundary == null) 114 boundary = "----" + UUID.randomUUID(); 115 return boundary; 116 } 117 118 /** 119 * @return the parts 120 */ 121 public ArrayList<MimePart> getMimeParts() { 122 return parts; 123 } 124 125 public MimePart addMimePart() { 126 if(parts == null) 127 parts = new ArrayList<BluetoothMapbMessageMmsEmail.MimePart>(); 128 MimePart newPart = new MimePart(); 129 parts.add(newPart); 130 return newPart; 131 } 132 public String getDateString() { 133 SimpleDateFormat format = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US); 134 Date dateObj = new Date(date); 135 return format.format(dateObj); // Format according to RFC 2822 page 14 136 } 137 public long getDate() { 138 return date; 139 } 140 public void setDate(long date) { 141 this.date = date; 142 } 143 public String getSubject() { 144 return subject; 145 } 146 public void setSubject(String subject) { 147 this.subject = subject; 148 } 149 public ArrayList<Rfc822Token> getFrom() { 150 return from; 151 } 152 public void setFrom(ArrayList<Rfc822Token> from) { 153 this.from = from; 154 } 155 public void addFrom(String name, String address) { 156 if(this.from == null) 157 this.from = new ArrayList<Rfc822Token>(1); 158 this.from.add(new Rfc822Token(name, address, null)); 159 } 160 public ArrayList<Rfc822Token> getSender() { 161 return sender; 162 } 163 public void setSender(ArrayList<Rfc822Token> sender) { 164 this.sender = sender; 165 } 166 public void addSender(String name, String address) { 167 if(this.sender == null) 168 this.sender = new ArrayList<Rfc822Token>(1); 169 this.sender.add(new Rfc822Token(name,address,null)); 170 } 171 public ArrayList<Rfc822Token> getTo() { 172 return to; 173 } 174 public void setTo(ArrayList<Rfc822Token> to) { 175 this.to = to; 176 } 177 public void addTo(String name, String address) { 178 if(this.to == null) 179 this.to = new ArrayList<Rfc822Token>(1); 180 this.to.add(new Rfc822Token(name, address, null)); 181 } 182 public ArrayList<Rfc822Token> getCc() { 183 return cc; 184 } 185 public void setCc(ArrayList<Rfc822Token> cc) { 186 this.cc = cc; 187 } 188 public void addCc(String name, String address) { 189 if(this.cc == null) 190 this.cc = new ArrayList<Rfc822Token>(1); 191 this.cc.add(new Rfc822Token(name, address, null)); 192 } 193 public ArrayList<Rfc822Token> getBcc() { 194 return bcc; 195 } 196 public void setBcc(ArrayList<Rfc822Token> bcc) { 197 this.bcc = bcc; 198 } 199 public void addBcc(String name, String address) { 200 if(this.bcc == null) 201 this.bcc = new ArrayList<Rfc822Token>(1); 202 this.bcc.add(new Rfc822Token(name, address, null)); 203 } 204 public ArrayList<Rfc822Token> getReplyTo() { 205 return replyTo; 206 } 207 public void setReplyTo(ArrayList<Rfc822Token> replyTo) { 208 this.replyTo = replyTo; 209 } 210 public void addReplyTo(String name, String address) { 211 if(this.replyTo == null) 212 this.replyTo = new ArrayList<Rfc822Token>(1); 213 this.replyTo.add(new Rfc822Token(name, address, null)); 214 } 215 public void setMessageId(String messageId) { 216 this.messageId = messageId; 217 } 218 public String getMessageId() { 219 return messageId; 220 } 221 public void setContentType(String contentType) { 222 this.contentType = contentType; 223 } 224 public String getContentType() { 225 return contentType; 226 } 227 public void setTextOnly(boolean textOnly) { 228 this.textOnly = textOnly; 229 } 230 public boolean getTextOnly() { 231 return textOnly; 232 } 233 public void setIncludeAttachments(boolean includeAttachments) { 234 this.includeAttachments = includeAttachments; 235 } 236 public boolean getIncludeAttachments() { 237 return includeAttachments; 238 } 239 public void updateCharset() { 240 charset = null; 241 for(MimePart part : parts) { 242 if(part.contentType != null && 243 part.contentType.toUpperCase().contains("TEXT")) { 244 charset = "UTF-8"; 245 break; 246 } 247 } 248 } 249 public int getSize() { 250 int message_size = 0; 251 for(MimePart part : parts) { 252 message_size += part.data.length; 253 } 254 return message_size; 255 } 256 257 /** 258 * Encode an address header, and perform folding if needed. 259 * @param sb The stringBuilder to write to 260 * @param headerName The RFC 2822 header name 261 * @param addresses the reformatted address substrings to encode. 262 */ 263 public void encodeHeaderAddresses(StringBuilder sb, String headerName, 264 ArrayList<Rfc822Token> addresses) { 265 /* TODO: Do we need to encode the addresses if they contain illegal characters? 266 * This depends of the outcome of errata 4176. The current spec. states to use UTF-8 267 * where possible, but the RFCs states to use US-ASCII for the headers - hence encoding 268 * would be needed to support non US-ASCII characters. But the MAP spec states not to 269 * use any encoding... */ 270 int partLength, lineLength = 0; 271 lineLength += headerName.getBytes().length; 272 sb.append(headerName); 273 for(Rfc822Token address : addresses) { 274 partLength = address.toString().getBytes().length+1; 275 // Add folding if needed 276 if(lineLength + partLength >= 998) // max line length in RFC2822 277 { 278 sb.append("\r\n "); // Append a FWS (folding whitespace) 279 lineLength = 0; 280 } 281 sb.append(address.toString()).append(";"); 282 lineLength += partLength; 283 } 284 sb.append("\r\n"); 285 } 286 287 public void encodeHeaders(StringBuilder sb) throws UnsupportedEncodingException 288 { 289 /* TODO: From RFC-4356 - about the RFC-(2)822 headers: 290 * "Current Internet Message format requires that only 7-bit US-ASCII 291 * characters be present in headers. Non-7-bit characters in an address 292 * domain must be encoded with [IDN]. If there are any non-7-bit 293 * characters in the local part of an address, the message MUST be 294 * rejected. Non-7-bit characters elsewhere in a header MUST be encoded 295 * according to [Hdr-Enc]." 296 * We need to add the address encoding in encodeHeaderAddresses, but it is not 297 * straight forward, as it is unclear how to do this. */ 298 if (date != INVALID_VALUE) 299 sb.append("Date: ").append(getDateString()).append("\r\n"); 300 /* According to RFC-2822 headers must use US-ASCII, where the MAP specification states 301 * UTF-8 should be used for the entire <bmessage-body-content>. We let the MAP specification 302 * take precedence above the RFC-2822. The code to 303 */ 304 /* If we are to use US-ASCII anyway, here are the code for it. 305 if (subject != null){ 306 // Use base64 encoding for the subject, as it may contain non US-ASCII characters or other 307 // illegal (RFC822 header), and android do not seem to have encoders/decoders for quoted-printables 308 sb.append("Subject:").append("=?utf-8?B?"); 309 sb.append(Base64.encodeToString(subject.getBytes("utf-8"), Base64.DEFAULT)); 310 sb.append("?=\r\n"); 311 }*/ 312 if (subject != null) 313 sb.append("Subject: ").append(subject).append("\r\n"); 314 if(from != null) 315 encodeHeaderAddresses(sb, "From: ", from); // This includes folding if needed. 316 if(sender != null) 317 encodeHeaderAddresses(sb, "Sender: ", sender); // This includes folding if needed. 318 /* For MMS one recipient(to, cc or bcc) must exists, if none: 'To: undisclosed- 319 * recipients:;' could be used. 320 * TODO: Is this a valid solution for E-Mail? 321 */ 322 if(to == null && cc == null && bcc == null) 323 sb.append("To: undisclosed-recipients:;\r\n"); 324 if(to != null) 325 encodeHeaderAddresses(sb, "To: ", to); // This includes folding if needed. 326 if(cc != null) 327 encodeHeaderAddresses(sb, "Cc: ", cc); // This includes folding if needed. 328 if(bcc != null) 329 encodeHeaderAddresses(sb, "Bcc: ", bcc); // This includes folding if needed. 330 if(replyTo != null) 331 encodeHeaderAddresses(sb, "Reply-To: ", replyTo); // This includes folding if needed. 332 if(includeAttachments == true) 333 { 334 if(messageId != null) 335 sb.append("Message-Id: ").append(messageId).append("\r\n"); 336 if(contentType != null) 337 sb.append("Content-Type: ").append(contentType).append("; boundary=").append(getBoundary()).append("\r\n"); 338 } 339 sb.append("\r\n"); // If no headers exists, we still need two CRLF, hence keep it out of the if above. 340 } 341 342 /* Notes on MMS 343 * ------------ 344 * According to rfc4356 all headers of a MMS converted to an E-mail must use 345 * 7-bit encoding. According the the MAP specification only 8-bit encoding is 346 * allowed - hence the bMessage-body should contain no SMTP headers. (Which makes 347 * sense, since the info is already present in the bMessage properties.) 348 * The result is that no information from RFC4356 is needed, since it does not 349 * describe any mapping between MMS content and E-mail content. 350 * Suggestion: 351 * Clearly state in the MAP specification that 352 * only the actual message content should be included in the <bmessage-body-content>. 353 * Correct the Example to not include the E-mail headers, and in stead show how to 354 * include a picture or another binary attachment. 355 * 356 * If the headers should be included, clearly state which, as the example clearly shows 357 * that some of the headers should be excluded. 358 * Additionally it is not clear how to handle attachments. There is a parameter in the 359 * get message to include attachments, but since only 8-bit encoding is allowed, 360 * (hence neither base64 nor binary) there is no mechanism to embed the attachment in 361 * the <bmessage-body-content>. 362 * 363 * UPDATE: Errata 4176 allows the needed encoding typed inside the <bmessage-body-content> 364 * including Base64 and Quoted Printables - hence it is possible to encode non-us-ascii 365 * messages - e.g. pictures and utf-8 strings with non-us-ascii content. 366 * It have not yet been adopted, but since the comments clearly suggest that it is allowed 367 * to use encoding schemes for non-text parts, it is still not clear what to do about non 368 * US-ASCII text in the headers. 369 * */ 370 371 /** 372 * Encode the bMessage as a MMS 373 * @return 374 * @throws UnsupportedEncodingException 375 */ 376 public byte[] encodeMms() throws UnsupportedEncodingException 377 { 378 ArrayList<byte[]> bodyFragments = new ArrayList<byte[]>(); 379 StringBuilder sb = new StringBuilder(); 380 int count = 0; 381 String mmsBody; 382 383 encoding = "8BIT"; // The encoding used 384 385 encodeHeaders(sb); 386 if(getIncludeAttachments() == false) { 387 for(MimePart part : parts) { 388 part.encodePlainText(sb); /* We call encode on all parts, to include a tag, where an attachment is missing. */ 389 } 390 } else { 391 for(MimePart part : parts) { 392 count++; 393 part.encode(sb, getBoundary(), (count == parts.size())); 394 } 395 } 396 397 mmsBody = sb.toString(); 398 399 if(mmsBody != null) { 400 String tmpBody = mmsBody.replaceAll("END:MSG", "/END\\:MSG"); // Replace any occurrences of END:MSG with \END:MSG 401 bodyFragments.add(tmpBody.getBytes("UTF-8")); 402 } else { 403 bodyFragments.add(new byte[0]); 404 } 405 406 return encodeGeneric(bodyFragments); 407 } 408 409 410 /** 411 * Try to parse the hdrPart string as e-mail headers. 412 * @param hdrPart The string to parse. 413 * @return Null if the entire string were e-mail headers. The part of the string in which 414 * no headers were found. 415 */ 416 private String parseMmsHeaders(String hdrPart) { 417 String[] headers = hdrPart.split("\r\n"); 418 String header; 419 hasHeaders = false; 420 421 for(int i = 0, c = headers.length; i < c; i++) { 422 header = headers[i]; 423 424 /* We need to figure out if any headers are present, in cases where devices do not follow the e-mail RFCs. 425 * Skip empty lines, and then parse headers until a non-header line is found, at which point we treat the 426 * remaining as plain text. 427 */ 428 if(header.trim() == "") 429 continue; 430 String[] headerParts = header.split(":",2); 431 if(headerParts.length != 2) { 432 // We treat the remaining content as plain text. 433 StringBuilder remaining = new StringBuilder(); 434 for(; i < c; i++) 435 remaining.append(headers[i]); 436 437 return remaining.toString(); 438 } 439 440 String headerType = headerParts[0].toUpperCase(); 441 String headerValue = headerParts[1].trim(); 442 443 // Address headers 444 /* TODO: If this is empty, the MSE needs to fill it in before sending the message. 445 * This happens when sending the MMS, not sure what happens for e-mail. 446 */ 447 if(headerType.contains("FROM")) { 448 Rfc822Token tokens[] = Rfc822Tokenizer.tokenize(headerValue); 449 from = new ArrayList<Rfc822Token>(Arrays.asList(tokens)); 450 } 451 else if(headerType.contains("TO")) { 452 Rfc822Token tokens[] = Rfc822Tokenizer.tokenize(headerValue); 453 to = new ArrayList<Rfc822Token>(Arrays.asList(tokens)); 454 } 455 else if(headerType.contains("CC")) { 456 Rfc822Token tokens[] = Rfc822Tokenizer.tokenize(headerValue); 457 cc = new ArrayList<Rfc822Token>(Arrays.asList(tokens)); 458 } 459 else if(headerType.contains("BCC")) { 460 Rfc822Token tokens[] = Rfc822Tokenizer.tokenize(headerValue); 461 bcc = new ArrayList<Rfc822Token>(Arrays.asList(tokens)); 462 } 463 else if(headerType.contains("REPLY-TO")) { 464 Rfc822Token tokens[] = Rfc822Tokenizer.tokenize(headerValue); 465 replyTo = new ArrayList<Rfc822Token>(Arrays.asList(tokens)); 466 }// Other headers 467 else if(headerType.contains("SUBJECT")) { 468 subject = headerValue; 469 } 470 else if(headerType.contains("MESSAGE-ID")) { 471 messageId = headerValue; 472 } 473 else if(headerType.contains("DATE")) { 474 /* TODO: Do we need the date? */ 475 } 476 else if(headerType.contains("CONTENT-TYPE")) { 477 String[] contentTypeParts = headerValue.split(";"); 478 contentType = contentTypeParts[0]; 479 // Extract the boundary if it exists 480 for(int j=1, n=contentTypeParts.length; j<n; j++) 481 { 482 if(contentTypeParts[j].contains("boundary")) { 483 boundary = contentTypeParts[j].split("boundary[\\s]*=", 2)[1].trim(); 484 } 485 } 486 } 487 else if(headerType.contains("CONTENT-TRANSFER-ENCODING")) { 488 encoding = headerValue; 489 } 490 else { 491 if(D) Log.w(TAG,"Skipping unknown header: " + headerType + " (" + header + ")"); 492 } 493 } 494 return null; 495 } 496 497 private void parseMmsMimePart(String partStr) { 498 String[] parts = partStr.split("\r\n\r\n", 2); // Split the header from the body 499 String body; 500 if(parts.length != 2) { 501 body = partStr; 502 } else { 503 body = parts[1]; 504 } 505 String[] headers = parts[0].split("\r\n"); 506 MimePart newPart = addMimePart(); 507 String partEncoding = encoding; /* Use the overall encoding as default */ 508 509 for(String header : headers) { 510 if(header.length() == 0) 511 continue; 512 513 if(header.trim() == "" || header.trim().equals("--")) // Skip empty lines(the \r\n after the boundary tag) and endBoundary tags 514 continue; 515 String[] headerParts = header.split(":",2); 516 if(headerParts.length != 2) 517 throw new IllegalArgumentException("part-Header not formatted correctly: " + header); 518 String headerType = headerParts[0].toUpperCase(); 519 String headerValue = headerParts[1].trim(); 520 if(headerType.contains("CONTENT-TYPE")) { 521 // TODO: extract charset? Only UTF-8 is allowed for TEXT typed parts 522 newPart.contentType = headerValue; 523 Log.d(TAG, "*** CONTENT-TYPE: " + newPart.contentType); 524 } 525 else if(headerType.contains("CONTENT-LOCATION")) { 526 // This is used if the smil refers to a file name in its src= 527 newPart.contentLocation = headerValue; 528 newPart.partName = headerValue; 529 } 530 else if(headerType.contains("CONTENT-TRANSFER-ENCODING")) { 531 partEncoding = headerValue; 532 } 533 else if(headerType.contains("CONTENT-ID")) { 534 // This is used if the smil refers to a cid:<xxx> in it's src= 535 newPart.contentId = headerValue; 536 } 537 else if(headerType.contains("CONTENT-DISPOSITION")) { 538 // This is used if the smil refers to a cid:<xxx> in it's src= 539 newPart.contentDisposition = headerValue; 540 } 541 else { 542 if(D) Log.w(TAG,"Skipping unknown part-header: " + headerType + " (" + header + ")"); 543 } 544 } 545 // Now for the body 546 newPart.data = decodeBody(body, partEncoding); 547 } 548 549 private void parseMmsMimeBody(String body) { 550 MimePart newPart = addMimePart(); 551 newPart.data = decodeBody(body, encoding); 552 } 553 554 private byte[] decodeBody(String body, String encoding) { 555 if(encoding != null && encoding.toUpperCase().contains("BASE64")) { 556 return Base64.decode(body, Base64.DEFAULT); 557 } else { 558 // TODO: handle other encoding types? - here we simply store the string data as bytes 559 try { 560 return body.getBytes("UTF-8"); 561 } catch (UnsupportedEncodingException e) { 562 // This will never happen, as UTF-8 is mandatory on Android platforms 563 } 564 } 565 return null; 566 } 567 568 private void parseMms(String message) { 569 /* Overall strategy for decoding: 570 * 1) split on first empty line to extract the header 571 * 2) unfold and parse headers 572 * 3) split on boundary to split into parts (or use the remaining as a part, 573 * if part is not found) 574 * 4) parse each part 575 * */ 576 String[] messageParts; 577 String[] mimeParts; 578 String remaining = null; 579 String messageBody = null; 580 message = message.replaceAll("\\r\\n[ \\\t]+", ""); // Unfold 581 messageParts = message.split("\r\n\r\n", 2); // Split the header from the body 582 if(messageParts.length != 2) { 583 // Handle entire message as plain text 584 messageBody = message; 585 } 586 else 587 { 588 remaining = parseMmsHeaders(messageParts[0]); 589 // If we have some text not being a header, add it to the message body. 590 if(remaining != null) { 591 messageBody = remaining + messageParts[1]; 592 } 593 else { 594 messageBody = messageParts[1]; 595 } 596 } 597 598 if(boundary == null) 599 { 600 // If the boundary is not set, handle as non-multi-part 601 parseMmsMimeBody(messageBody); 602 setTextOnly(true); 603 if(contentType == null) 604 contentType = "text/plain"; 605 parts.get(0).contentType = contentType; 606 } 607 else 608 { 609 mimeParts = messageBody.split("--" + boundary); 610 for(int i = 0; i < mimeParts.length - 1; i++) { 611 String part = mimeParts[i]; 612 if (part != null && (part.length() > 0)) 613 parseMmsMimePart(part); 614 } 615 } 616 } 617 618 /* Notes on SMIL decoding (from http://tools.ietf.org/html/rfc2557): 619 * src="filename.jpg" refers to a part with Content-Location: filename.jpg 620 * src="cid:1234 (at) hest.net" refers to a part with Content-ID:<1234 (at) hest.net>*/ 621 @Override 622 public void parseMsgPart(String msgPart) { 623 parseMms(msgPart); 624 625 } 626 627 @Override 628 public void parseMsgInit() { 629 // Not used for e-mail 630 631 } 632 633 @Override 634 public byte[] encode() throws UnsupportedEncodingException { 635 return encodeMms(); 636 } 637 638 } 639