/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.voicemail.impl.mail; import android.os.Parcel; import android.os.Parcelable; import android.support.annotation.VisibleForTesting; import android.text.Html; import android.text.TextUtils; import android.text.util.Rfc822Token; import android.text.util.Rfc822Tokenizer; import com.android.voicemail.impl.mail.utils.LogUtils; import java.util.ArrayList; import java.util.regex.Pattern; import org.apache.james.mime4j.codec.DecodeMonitor; import org.apache.james.mime4j.codec.DecoderUtil; import org.apache.james.mime4j.codec.EncoderUtil; /** * This class represent email address. * *

RFC822 email address may have following format. "name"

(comment) "name"
* name
address Name and comment part should be MIME/base64 encoded in header if * necessary. */ public class Address implements Parcelable { public static final String ADDRESS_DELIMETER = ","; /** Address part, in the form local_part@domain_part. No surrounding angle brackets. */ private String address; /** * Name part. No surrounding double quote, and no MIME/base64 encoding. This must be null if * Address has no name part. */ private String personal; /** * When personal is set, it will return the first token of the personal string. Otherwise, it will * return the e-mail address up to the '@' sign. */ private String simplifiedName; // Regex that matches address surrounded by '<>' optionally. '^]+)>?$' private static final Pattern REMOVE_OPTIONAL_BRACKET = Pattern.compile("^]+)>?$"); // Regex that matches personal name surrounded by '""' optionally. '^"?([^"]+)"?$' private static final Pattern REMOVE_OPTIONAL_DQUOTE = Pattern.compile("^\"?([^\"]*)\"?$"); // Regex that matches escaped character '\\([\\"])' private static final Pattern UNQUOTE = Pattern.compile("\\\\([\\\\\"])"); // TODO: LOCAL_PART and DOMAIN_PART_PART are too permissive and can be improved. // TODO: Fix this to better constrain comments. /** Regex for the local part of an email address. */ private static final String LOCAL_PART = "[^@]+"; /** Regex for each part of the domain part, i.e. the thing between the dots. */ private static final String DOMAIN_PART_PART = "[[\\w][\\d]\\-\\(\\)\\[\\]]+"; /** Regex for the domain part, which is two or more {@link #DOMAIN_PART_PART} separated by . */ private static final String DOMAIN_PART = "(" + DOMAIN_PART_PART + "\\.)+" + DOMAIN_PART_PART; /** Pattern to check if an email address is valid. */ private static final Pattern EMAIL_ADDRESS = Pattern.compile("\\A" + LOCAL_PART + "@" + DOMAIN_PART + "\\z"); private static final Address[] EMPTY_ADDRESS_ARRAY = new Address[0]; // delimiters are chars that do not appear in an email address, used by fromHeader private static final char LIST_DELIMITER_EMAIL = '\1'; private static final char LIST_DELIMITER_PERSONAL = '\2'; private static final String LOG_TAG = "Email Address"; @VisibleForTesting public Address(String address) { setAddress(address); } public Address(String address, String personal) { setPersonal(personal); setAddress(address); } /** * Returns a simplified string for this e-mail address. When a name is known, it will return the * first token of that name. Otherwise, it will return the e-mail address up to the '@' sign. */ public String getSimplifiedName() { if (simplifiedName == null) { if (TextUtils.isEmpty(personal) && !TextUtils.isEmpty(address)) { int atSign = address.indexOf('@'); simplifiedName = (atSign != -1) ? address.substring(0, atSign) : ""; } else if (!TextUtils.isEmpty(personal)) { // TODO: use Contacts' NameSplitter for more reliable first-name extraction int end = personal.indexOf(' '); while (end > 0 && personal.charAt(end - 1) == ',') { end--; } simplifiedName = (end < 1) ? personal : personal.substring(0, end); } else { LogUtils.w(LOG_TAG, "Unable to get a simplified name"); simplifiedName = ""; } } return simplifiedName; } public static synchronized Address getEmailAddress(String rawAddress) { if (TextUtils.isEmpty(rawAddress)) { return null; } String name; String address; final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(rawAddress); if (tokens.length > 0) { final String tokenizedName = tokens[0].getName(); name = tokenizedName != null ? Html.fromHtml(tokenizedName.trim()).toString() : ""; address = Html.fromHtml(tokens[0].getAddress()).toString(); } else { name = ""; address = rawAddress == null ? "" : Html.fromHtml(rawAddress).toString(); } return new Address(address, name); } public String getAddress() { return address; } public void setAddress(String address) { this.address = REMOVE_OPTIONAL_BRACKET.matcher(address).replaceAll("$1"); } /** * Get name part as UTF-16 string. No surrounding double quote, and no MIME/base64 encoding. * * @return Name part of email address. Returns null if it is omitted. */ public String getPersonal() { return personal; } /** * Set personal part from UTF-16 string. Optional surrounding double quote will be removed. It * will be also unquoted and MIME/base64 decoded. * * @param personal name part of email address as UTF-16 string. Null is acceptable. */ public void setPersonal(String personal) { this.personal = decodeAddressPersonal(personal); } /** * Decodes name from UTF-16 string. Optional surrounding double quote will be removed. It will be * also unquoted and MIME/base64 decoded. * * @param personal name part of email address as UTF-16 string. Null is acceptable. */ public static String decodeAddressPersonal(String personal) { if (personal != null) { personal = REMOVE_OPTIONAL_DQUOTE.matcher(personal).replaceAll("$1"); personal = UNQUOTE.matcher(personal).replaceAll("$1"); personal = DecoderUtil.decodeEncodedWords(personal, DecodeMonitor.STRICT); if (personal.length() == 0) { personal = null; } } return personal; } /** * This method is used to check that all the addresses that the user entered in a list (e.g. To:) * are valid, so that none is dropped. */ @VisibleForTesting public static boolean isAllValid(String addressList) { // This code mimics the parse() method below. // I don't know how to better avoid the code-duplication. if (addressList != null && addressList.length() > 0) { Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(addressList); for (int i = 0, length = tokens.length; i < length; ++i) { Rfc822Token token = tokens[i]; String address = token.getAddress(); if (!TextUtils.isEmpty(address) && !isValidAddress(address)) { return false; } } } return true; } /** * Parse a comma-delimited list of addresses in RFC822 format and return an array of Address * objects. * * @param addressList Address list in comma-delimited string. * @return An array of 0 or more Addresses. */ public static Address[] parse(String addressList) { if (addressList == null || addressList.length() == 0) { return EMPTY_ADDRESS_ARRAY; } Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(addressList); ArrayList
addresses = new ArrayList
(); for (int i = 0, length = tokens.length; i < length; ++i) { Rfc822Token token = tokens[i]; String address = token.getAddress(); if (!TextUtils.isEmpty(address)) { if (isValidAddress(address)) { String name = token.getName(); if (TextUtils.isEmpty(name)) { name = null; } addresses.add(new Address(address, name)); } } } return addresses.toArray(new Address[addresses.size()]); } /** Checks whether a string email address is valid. E.g. name@domain.com is valid. */ @VisibleForTesting static boolean isValidAddress(final String address) { return EMAIL_ADDRESS.matcher(address).find(); } @Override public boolean equals(Object o) { if (o instanceof Address) { // It seems that the spec says that the "user" part is case-sensitive, // while the domain part in case-insesitive. // So foo@yahoo.com and Foo@yahoo.com are different. // This may seem non-intuitive from the user POV, so we // may re-consider it if it creates UI trouble. // A problem case is "replyAll" sending to both // a@b.c and to A@b.c, which turn out to be the same on the server. // Leave unchanged for now (i.e. case-sensitive). return getAddress().equals(((Address) o).getAddress()); } return super.equals(o); } @Override public int hashCode() { return getAddress().hashCode(); } /** * Get human readable address string. Do not use this for email header. * * @return Human readable address string. Not quoted and not encoded. */ @Override public String toString() { if (personal != null && !personal.equals(address)) { if (personal.matches(".*[\\(\\)<>@,;:\\\\\".\\[\\]].*")) { return ensureQuotedString(personal) + " <" + address + ">"; } else { return personal + " <" + address + ">"; } } else { return address; } } /** * Ensures that the given string starts and ends with the double quote character. The string is * not modified in any way except to add the double quote character to start and end if it's not * already there. * *

sample -> "sample" "sample" -> "sample" ""sample"" -> "sample" "sample"" -> "sample" * sa"mp"le -> "sa"mp"le" "sa"mp"le" -> "sa"mp"le" (empty string) -> "" " -> "" */ private static String ensureQuotedString(String s) { if (s == null) { return null; } if (!s.matches("^\".*\"$")) { return "\"" + s + "\""; } else { return s; } } /** * Get human readable comma-delimited address string. * * @param addresses Address array * @return Human readable comma-delimited address string. */ @VisibleForTesting public static String toString(Address[] addresses) { return toString(addresses, ADDRESS_DELIMETER); } /** * Get human readable address strings joined with the specified separator. * * @param addresses Address array * @param separator Separator * @return Human readable comma-delimited address string. */ public static String toString(Address[] addresses, String separator) { if (addresses == null || addresses.length == 0) { return null; } if (addresses.length == 1) { return addresses[0].toString(); } StringBuilder sb = new StringBuilder(addresses[0].toString()); for (int i = 1; i < addresses.length; i++) { sb.append(separator); // TODO: investigate why this .trim() is needed. sb.append(addresses[i].toString().trim()); } return sb.toString(); } /** * Get RFC822/MIME compatible address string. * * @return RFC822/MIME compatible address string. It may be surrounded by double quote or quoted * and MIME/base64 encoded if necessary. */ public String toHeader() { if (personal != null) { return EncoderUtil.encodeAddressDisplayName(personal) + " <" + address + ">"; } else { return address; } } /** * Get RFC822/MIME compatible comma-delimited address string. * * @param addresses Address array * @return RFC822/MIME compatible comma-delimited address string. it may be surrounded by double * quoted or quoted and MIME/base64 encoded if necessary. */ public static String toHeader(Address[] addresses) { if (addresses == null || addresses.length == 0) { return null; } if (addresses.length == 1) { return addresses[0].toHeader(); } StringBuilder sb = new StringBuilder(addresses[0].toHeader()); for (int i = 1; i < addresses.length; i++) { // We need space character to be able to fold line. sb.append(", "); sb.append(addresses[i].toHeader()); } return sb.toString(); } /** * Get Human friendly address string. * * @return the personal part of this Address, or the address part if the personal part is not * available */ @VisibleForTesting public String toFriendly() { if (personal != null && personal.length() > 0) { return personal; } else { return address; } } /** * Creates a comma-delimited list of addresses in the "friendly" format (see toFriendly() for * details on the per-address conversion). * * @param addresses Array of Address[] values * @return A comma-delimited string listing all of the addresses supplied. Null if source was null * or empty. */ @VisibleForTesting public static String toFriendly(Address[] addresses) { if (addresses == null || addresses.length == 0) { return null; } if (addresses.length == 1) { return addresses[0].toFriendly(); } StringBuilder sb = new StringBuilder(addresses[0].toFriendly()); for (int i = 1; i < addresses.length; i++) { sb.append(", "); sb.append(addresses[i].toFriendly()); } return sb.toString(); } /** Returns exactly the same result as Address.toString(Address.fromHeader(addressList)). */ @VisibleForTesting public static String fromHeaderToString(String addressList) { return toString(fromHeader(addressList)); } /** Returns exactly the same result as Address.toHeader(Address.parse(addressList)). */ @VisibleForTesting public static String parseToHeader(String addressList) { return Address.toHeader(Address.parse(addressList)); } /** * Returns null if the addressList has 0 addresses, otherwise returns the first address. The same * as Address.fromHeader(addressList)[0] for non-empty list. This is an utility method that offers * some performance optimization opportunities. */ @VisibleForTesting public static Address firstAddress(String addressList) { Address[] array = fromHeader(addressList); return array.length > 0 ? array[0] : null; } /** * This method exists to convert an address list formatted in a deprecated legacy format to the * standard RFC822 header format. {@link #fromHeader(String)} is capable of reading the legacy * format and the RFC822 format. {@link #toHeader()} always produces the RFC822 format. * *

This implementation is brute-force, and could be replaced with a more efficient version if * desired. */ public static String reformatToHeader(String addressList) { return toHeader(fromHeader(addressList)); } /** * @param addressList a CSV of RFC822 addresses or the deprecated legacy string format * @return array of addresses parsed from addressList */ @VisibleForTesting public static Address[] fromHeader(String addressList) { if (addressList == null || addressList.length() == 0) { return EMPTY_ADDRESS_ARRAY; } // IF we're CSV, just parse if ((addressList.indexOf(LIST_DELIMITER_PERSONAL) == -1) && (addressList.indexOf(LIST_DELIMITER_EMAIL) == -1)) { return Address.parse(addressList); } // Otherwise, do backward-compatible unpack ArrayList

addresses = new ArrayList
(); int length = addressList.length(); int pairStartIndex = 0; int pairEndIndex; /* addressEndIndex is only re-scanned (indexOf()) when a LIST_DELIMITER_PERSONAL is used, not for every email address; i.e. not for every iteration of the while(). This reduces the theoretical complexity from quadratic to linear, and provides some speed-up in practice by removing redundant scans of the string. */ int addressEndIndex = addressList.indexOf(LIST_DELIMITER_PERSONAL); while (pairStartIndex < length) { pairEndIndex = addressList.indexOf(LIST_DELIMITER_EMAIL, pairStartIndex); if (pairEndIndex == -1) { pairEndIndex = length; } Address address; if (addressEndIndex == -1 || pairEndIndex <= addressEndIndex) { // in this case the DELIMITER_PERSONAL is in a future pair, // so don't use personal, and don't update addressEndIndex address = new Address(addressList.substring(pairStartIndex, pairEndIndex), null); } else { address = new Address( addressList.substring(pairStartIndex, addressEndIndex), addressList.substring(addressEndIndex + 1, pairEndIndex)); // only update addressEndIndex when we use the LIST_DELIMITER_PERSONAL addressEndIndex = addressList.indexOf(LIST_DELIMITER_PERSONAL, pairEndIndex + 1); } addresses.add(address); pairStartIndex = pairEndIndex + 1; } return addresses.toArray(new Address[addresses.size()]); } public static final Creator
CREATOR = new Creator
() { @Override public Address createFromParcel(Parcel parcel) { return new Address(parcel); } @Override public Address[] newArray(int size) { return new Address[size]; } }; public Address(Parcel in) { setPersonal(in.readString()); setAddress(in.readString()); } @Override public int describeContents() { return 0; } @Override public void writeToParcel(Parcel out, int flags) { out.writeString(personal); out.writeString(address); } }