Exchange calendar: fixes for the ICS writer.

- Now SimpleIcsWriter does the UTF-8 conversion, and folds lines according
  to the number of bytes in UTF-8.
- It now escapes special chars in TEXT values.  You can safely put , ; \ or
  line breaks. in summary, location, and description.
- Quotes all CN.  (leftover from Ibb8f155a)
- Replace "s in CN with 's (rather than removing them)

Bug 2508283
Bug 2515768

Change-Id: Ibdced53ee32bba950608d63f507b11b24eaad7b0
This commit is contained in:
Makoto Onuki 2010-03-16 11:08:46 -07:00
parent 40be6b976c
commit 88a94bca19
6 changed files with 213 additions and 93 deletions

View File

@ -32,7 +32,6 @@ import android.content.res.TypedArray;
import android.database.Cursor;
import android.graphics.drawable.Drawable;
import android.text.Editable;
import android.util.Log;
import android.util.base64.Base64;
import android.widget.TextView;
@ -475,4 +474,12 @@ public class Utility {
buffer.get(bytes);
return bytes;
}
/**
* @return true if the input is the first (or only) byte in a UTF-8 character
*/
public static boolean isFirstUtf8Byte(byte b) {
// If the top 2 bits is '10', it's not a first byte.
return (b & 0xc0) != 0x80;
}
}

View File

@ -1418,7 +1418,8 @@ public class CalendarUtilities {
}
if (icalTag != null) {
if (attendeeName != null) {
icalTag += ";CN=" + attendeeName;
icalTag += ";CN="
+ SimpleIcsWriter.quoteParamValue(attendeeName);
}
ics.writeTag(icalTag, "MAILTO:" + attendeeEmail);
}
@ -1433,7 +1434,7 @@ public class CalendarUtilities {
// We should be able to find this, assuming the Email is the user's email
// TODO Find this in the account
if (organizerName != null) {
icalTag += ";CN=" + organizerName;
icalTag += ";CN=" + SimpleIcsWriter.quoteParamValue(organizerName);
}
ics.writeTag(icalTag, "MAILTO:" + organizerEmail);
if (method.equals("REPLY")) {
@ -1460,15 +1461,10 @@ public class CalendarUtilities {
ics.writeTag("SEQUENCE", sequence);
ics.writeTag("END", "VEVENT");
ics.writeTag("END", "VCALENDAR");
ics.flush();
ics.close();
// Create the ics attachment using the "content" field
Attachment att = new Attachment();
// TODO UTF-8 conversion should be done in SimpleIcsWriter, as it should count line
// length for folding in bytes in UTF-8.
att.mContentBytes = Utility.toUtf8(ics.toString());
att.mContentBytes = ics.getBytes();
att.mMimeType = "text/calendar; method=" + method;
att.mFileName = "invite.ics";
att.mSize = att.mContentBytes.length;

View File

@ -15,59 +15,98 @@
package com.android.exchange.utility;
import java.io.CharArrayWriter;
import com.android.email.Utility;
import android.text.TextUtils;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
public class SimpleIcsWriter extends CharArrayWriter {
public static final int MAX_LINE_LENGTH = 75;
public static final int LINE_BREAK_LENGTH = 3;
public static final String LINE_BREAK = "\r\n\t";
int mColumnCount = 0;
/**
* Class to generate iCalender object (*.ics) per RFC 5545.
*/
public class SimpleIcsWriter {
private static final int MAX_LINE_LENGTH = 75; // In bytes, excluding CRLF
private static final int CHAR_MAX_BYTES_IN_UTF8 = 4; // Used to be 6, but RFC3629 limited it.
private final ByteArrayOutputStream mOut = new ByteArrayOutputStream();
public SimpleIcsWriter() {
super();
}
private void newLine() {
write('\r');
write('\n');
mColumnCount = 0;
}
@Override
public void write(String str) throws IOException {
int len = str.length();
for (int i = 0; i < len; i++, mColumnCount++) {
if (mColumnCount == MAX_LINE_LENGTH) {
write('\r');
write('\n');
write('\t');
// Line count will get immediately incremented to one (the tab)
mColumnCount = 0;
/**
* Low level method to write a line, performing line-folding if necessary.
*/
/* package for testing */ void writeLine(String string) {
int numBytes = 0;
for (byte b : Utility.toUtf8(string)) {
// Fold it when necessary.
// To make it simple, we assume all chars are 4 bytes.
// If not (and usually it's not), we end up wrapping earlier than necessary, but that's
// completely fine.
if (numBytes > (MAX_LINE_LENGTH - CHAR_MAX_BYTES_IN_UTF8)
&& Utility.isFirstUtf8Byte(b)) { // Only wrappable if it's before the first byte
mOut.write((byte) '\r');
mOut.write((byte) '\n');
mOut.write((byte) '\t');
numBytes = 1; // for TAB
}
char c = str.charAt(i);
if (c == '\r') {
// Ignore CR
mColumnCount--;
continue;
} else if (c == '\n') {
// On LF, set to -1, which will immediately get incremented to zero
write("\\");
write("n");
mColumnCount = -1;
continue;
}
write(c);
mOut.write(b);
numBytes++;
}
mOut.write((byte) '\r');
mOut.write((byte) '\n');
}
public void writeTag(String name, String value) throws IOException {
/**
* Write a tag with a value.
*/
public void writeTag(String name, String value) {
// Belt and suspenders here; don't crash on null value. Use something innocuous
if (value == null) value = "0";
write(name);
write(":");
write(value);
newLine();
if (TextUtils.isEmpty(value)) {
value = "0";
}
// The following properties take a TEXT value, which need to be escaped.
// (These property names should be all interned, so using equals() should be faster than
// using a hash table.)
// TODO make constants for these literals
if ("CALSCALE".equals(name)
|| "METHOD".equals(name)
|| "PRODID".equals(name)
|| "VERSION".equals(name)
|| "CATEGORIES".equals(name)
|| "CLASS".equals(name)
|| "COMMENT".equals(name)
|| "DESCRIPTION".equals(name)
|| "LOCATION".equals(name)
|| "RESOURCES".equals(name)
|| "STATUS".equals(name)
|| "SUMMARY".equals(name)
|| "TRANSP".equals(name)
|| "TZID".equals(name)
|| "TZNAME".equals(name)
|| "CONTACT".equals(name)
|| "RELATED-TO".equals(name)
|| "UID".equals(name)
|| "ACTION".equals(name)
|| "REQUEST-STATUS".equals(name)
|| "X-LIC-LOCATION".equals(name)
) {
value = escapeTextValue(value);
}
writeLine(name + ":" + value);
}
/**
* @return the entire iCalendar invitation object.
*/
public byte[] getBytes() {
try {
mOut.flush();
} catch (IOException wonthappen) {
}
return mOut.toByteArray();
}
/**
@ -77,9 +116,32 @@ public class SimpleIcsWriter extends CharArrayWriter {
if (paramValue == null) {
return null;
}
// Wrap with double quotes. You can't put double-quotes itself in it, so remove them first.
// We can be smarter -- e.g. we don't have to wrap an empty string with dquotes -- but
// we don't have to.
return "\"" + paramValue.replace("\"", "") + "\"";
// Wrap with double quotes.
// The spec doesn't allow putting double-quotes in a param value, so let's use single quotes
// as a substitute.
// It's not the smartest implementation. e.g. we don't have to wrap an empty string with
// double quotes. But it works.
return "\"" + paramValue.replace("\"", "'") + "\"";
}
/**
* Escape a TEXT value per RFC 5545 section 3.3.11
*/
/* package for testing */ static String escapeTextValue(String s) {
StringBuilder sb = new StringBuilder(s.length());
for (int i = 0; i < s.length(); i++) {
char ch = s.charAt(i);
if (ch == '\n') {
sb.append("\\n");
} else if (ch == '\r') {
// Remove CR
} else if (ch == ',' || ch == ';' || ch == '\\') {
sb.append('\\');
sb.append(ch);
} else {
sb.append(ch);
}
}
return sb.toString();
}
}

View File

@ -124,4 +124,22 @@ public class UtilityUnitTests extends AndroidTestCase {
MoreAsserts.assertEquals(TestUtils.b(0xE6, 0x97, 0xA5, 0xE6, 0x9C, 0xAC, 0xE8, 0xAA, 0x9E),
Utility.toUtf8("\u65E5\u672C\u8A9E"));
}
public void testIsFirstUtf8Byte() {
// 1 byte in UTF-8.
checkIsFirstUtf8Byte("0"); // First 2 bits: 00
checkIsFirstUtf8Byte("A"); // First 2 bits: 01
checkIsFirstUtf8Byte("\u00A2"); // 2 bytes in UTF-8.
checkIsFirstUtf8Byte("\u20AC"); // 3 bytes in UTF-8.
checkIsFirstUtf8Byte("\uD852\uDF62"); // 4 bytes in UTF-8. (surrogate pair)
}
private void checkIsFirstUtf8Byte(String aChar) {
byte[] bytes = Utility.toUtf8(aChar);
assertTrue("0", Utility.isFirstUtf8Byte(bytes[0]));
for (int i = 1; i < bytes.length; i++) {
assertFalse(Integer.toString(i), Utility.isFirstUtf8Byte(bytes[i]));
}
}
}

View File

@ -499,8 +499,8 @@ public class CalendarUtilitiesTests extends AndroidTestCase {
// We shouldn't ever see an empty line
throw new IllegalArgumentException();
}
// A line starting with tab after a 75 character line is a continuation
if (line.charAt(0) == '\t' && lastLength == SimpleIcsWriter.MAX_LINE_LENGTH) {
// A line starting with tab is a continuation
if (line.charAt(0) == '\t') {
// Remember the line and length
lastValue = line.substring(1);
lastLength = line.length();

View File

@ -15,63 +15,100 @@
package com.android.exchange.utility;
import java.io.IOException;
import com.android.email.TestUtils;
import junit.framework.TestCase;
/**
* Tests of EAS Calendar Utilities
* Test for {@link SimpleIcsWriter}.
* You can run this entire test case with:
* runtest -c com.android.exchange.utility.SimpleIcsWriterTests email
*/
public class SimpleIcsWriterTests extends TestCase {
private final String string63Chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789*";
private final String string80Chars =
"ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ12345" + "67890";
private static final String tag11Chars = "DESCRIPTION";
private static final String CRLF = "\r\n";
private static final String UTF8_1_BYTE = "a";
private static final String UTF8_2_BYTES = "\u00A2";
private static final String UTF8_3_BYTES = "\u20AC";
private static final String UTF8_4_BYTES = "\uD852\uDF62";
// Where our line breaks should end up
private final String expectedFirstLineBreak =
string63Chars.charAt(string63Chars.length() - 1) + SimpleIcsWriter.LINE_BREAK;
private final String expectedSecondLineBreak =
string80Chars.charAt(SimpleIcsWriter.MAX_LINE_LENGTH - 1) + SimpleIcsWriter.LINE_BREAK;
/**
* Test for {@link SimpleIcsWriter#writeTag}. It also covers {@link SimpleIcsWriter#getBytes()}
* and {@link SimpleIcsWriter#escapeTextValue}.
*/
public void testWriteTag() {
final SimpleIcsWriter ics = new SimpleIcsWriter();
ics.writeTag("TAG1", null);
ics.writeTag("TAG2", "");
ics.writeTag("TAG3", "xyz");
ics.writeTag("SUMMARY", "TEST-TEST,;\r\n\\TEST");
ics.writeTag("SUMMARY2", "TEST-TEST,;\r\n\\TEST");
final String actual = TestUtils.fromUtf8(ics.getBytes());
public void testCrlf() throws IOException {
SimpleIcsWriter w = new SimpleIcsWriter();
w.writeTag("TAG", "A\r\nB\nC\r\nD");
String str = w.toString();
// Make sure \r's are stripped and that \n is turned into two chars, \ and n
assertEquals("TAG:A\\nB\\nC\\nD\r\n", str);
assertEquals(
"TAG1:0" + CRLF +
"TAG2:0" + CRLF +
"TAG3:xyz" + CRLF +
"SUMMARY:TEST-TEST\\,\\;\\n\\\\TEST" + CRLF + // escaped
"SUMMARY2:TEST-TEST,;\r\n\\TEST" + CRLF // not escaped
, actual);
}
public void testWriter() throws IOException {
// Sanity test on constant strings
assertEquals(63, string63Chars.length());
assertEquals(80, string80Chars.length());
// Add 1 for the colon between the tag and the value
assertEquals(SimpleIcsWriter.MAX_LINE_LENGTH,
tag11Chars.length() + 1 + string63Chars.length());
/**
* Verify that: We're folding lines correctly, and we're not splitting up a UTF-8 character.
*/
public void testWriteLine() {
for (String last : new String[] {UTF8_1_BYTE, UTF8_2_BYTES, UTF8_3_BYTES, UTF8_4_BYTES}) {
for (int i = 70; i < 160; i++) {
String input = stringOfLength(i) + last;
checkWriteLine(input);
}
}
}
SimpleIcsWriter w = new SimpleIcsWriter();
w.writeTag(tag11Chars, string63Chars + string80Chars);
/**
* @return a String of {@code length} bytes in UTF-8.
*/
private static String stringOfLength(int length) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
sb.append('0' +(i % 10));
}
return sb.toString();
}
// We should always end a tag on a new line
assertEquals(0, w.mColumnCount);
private void checkWriteLine(String input) {
final SimpleIcsWriter ics = new SimpleIcsWriter();
ics.writeLine(input);
final byte[] bytes = ics.getBytes();
// Get the final string
String str = w.toString();
assertEquals(SimpleIcsWriter.MAX_LINE_LENGTH-1, str.indexOf(expectedFirstLineBreak));
assertEquals(SimpleIcsWriter.MAX_LINE_LENGTH + SimpleIcsWriter.LINE_BREAK_LENGTH +
(SimpleIcsWriter.MAX_LINE_LENGTH - 1), str.indexOf(expectedSecondLineBreak));
// Verify that no lines are longer than 75 bytes.
int numBytes = 0;
for (byte b : bytes) {
if (b == '\r') {
continue; // ignore
}
if (b == '\n') {
assertTrue("input=" + input, numBytes <= 75);
numBytes = 0;
continue;
}
numBytes++;
}
assertTrue("input=" + input, numBytes <= 75);
// If we're splitting up a UTF-8 character, fromUtf8() won't restore it correctly.
// If it becomes the same as input, we're doing the right thing.
final String actual = TestUtils.fromUtf8(bytes);
final String unfolded = actual.replace("\r\n\t", "");
assertEquals("input=" + input, input + "\r\n", unfolded);
}
public void testQuoteParamValue() {
assertNull(SimpleIcsWriter.quoteParamValue(null));
assertEquals("\"\"", SimpleIcsWriter.quoteParamValue(""));
assertEquals("\"a\"", SimpleIcsWriter.quoteParamValue("a"));
assertEquals("\"\"", SimpleIcsWriter.quoteParamValue("\""));
assertEquals("\"''\"", SimpleIcsWriter.quoteParamValue("\"'"));
assertEquals("\"abc\"", SimpleIcsWriter.quoteParamValue("abc"));
assertEquals("\"abc\"", SimpleIcsWriter.quoteParamValue("a\"b\"c"));
assertEquals("\"a'b'c\"", SimpleIcsWriter.quoteParamValue("a\"b\"c"));
}
}