Make vCard importer/exporter aware of multi-byte parameters.

Based on the change Ic877940242d87ef918bf8d4dac601d37b296259b
Bug: 2922186

Change-Id: Id4cd674a0565670023b7bb1010b21d8349dd4daa
diff --git a/java/com/android/vcard/VCardBuilder.java b/java/com/android/vcard/VCardBuilder.java
index 84fc85a..4519f3c 100644
--- a/java/com/android/vcard/VCardBuilder.java
+++ b/java/com/android/vcard/VCardBuilder.java
@@ -1535,6 +1535,9 @@
                     parameterList.add(VCardConstants.PARAM_TYPE_VOICE);
                 } else if (VCardUtils.isMobilePhoneLabel(label)) {
                     parameterList.add(VCardConstants.PARAM_TYPE_CELL);
+                } else if (mIsV30) {
+                    // This label is appropriately encoded in appendTypeParameters.
+                    parameterList.add(label);
                 } else {
                     final String upperLabel = label.toUpperCase();
                     if (VCardUtils.isValidInV21ButUnknownToContactsPhoteType(upperLabel)) {
@@ -1814,21 +1817,35 @@
         // which would be recommended way in vcard 3.0 though not valid in vCard 2.1.
         boolean first = true;
         for (final String typeValue : types) {
-            // Note: vCard 3.0 specifies the different type of acceptable type Strings, but
-            //       we don't emit that kind of vCard 3.0 specific type since there should be
-            //       high probabilyty in which external importers cannot understand them.
-            //
-            // e.g. TYPE="\u578B\u306B\u3087" (vCard 3.0 allows non-Ascii characters if they
-            //      are quoted.)
-            if (!VCardUtils.isV21Word(typeValue)) {
-                continue;
+            if (VCardConfig.isVersion30(mVCardType)) {
+                final String encoded = VCardUtils.toStringAvailableAsV30ParamValue(typeValue);
+                if (TextUtils.isEmpty(encoded)) {
+                    continue;
+                }
+
+                // Note: vCard 3.0 specifies the different type of acceptable type Strings, but
+                //       we don't emit that kind of vCard 3.0 specific type since there should be
+                //       high probabilyty in which external importers cannot understand them.
+                //
+                // e.g. TYPE="\u578B\u306B\u3087" (vCard 3.0 allows non-Ascii characters if they
+                //      are quoted.)
+                if (first) {
+                    first = false;
+                } else {
+                    mBuilder.append(VCARD_PARAM_SEPARATOR);
+                }
+                appendTypeParameter(encoded);
+            } else {  // vCard 2.1
+                if (!VCardUtils.isV21Word(typeValue)) {
+                    continue;
+                }
+                if (first) {
+                    first = false;
+                } else {
+                    mBuilder.append(VCARD_PARAM_SEPARATOR);
+                }
+                appendTypeParameter(typeValue);
             }
-            if (first) {
-                first = false;
-            } else {
-                mBuilder.append(VCARD_PARAM_SEPARATOR);
-            }
-            appendTypeParameter(typeValue);
         }
     }
 
diff --git a/java/com/android/vcard/VCardEntryConstructor.java b/java/com/android/vcard/VCardEntryConstructor.java
index c722262..956d4b4 100644
--- a/java/com/android/vcard/VCardEntryConstructor.java
+++ b/java/com/android/vcard/VCardEntryConstructor.java
@@ -102,13 +102,15 @@
     public void addEntryHandler(VCardEntryHandler entryHandler) {
         mEntryHandlers.add(entryHandler);
     }
-    
+
+    @Override
     public void start() {
         for (VCardEntryHandler entryHandler : mEntryHandlers) {
             entryHandler.onStart();
         }
     }
 
+    @Override
     public void end() {
         for (VCardEntryHandler entryHandler : mEntryHandlers) {
             entryHandler.onEnd();
@@ -120,6 +122,7 @@
         mCurrentProperty = new VCardEntry.Property();
     }
 
+    @Override
     public void startEntry() {
         if (mCurrentVCardEntry != null) {
             Log.e(LOG_TAG, "Nested VCard code is not supported now.");
@@ -127,6 +130,7 @@
         mCurrentVCardEntry = new VCardEntry(mVCardType, mAccount);
     }
 
+    @Override
     public void endEntry() {
         mCurrentVCardEntry.consolidateFields();
         for (VCardEntryHandler entryHandler : mEntryHandlers) {
@@ -135,21 +139,26 @@
         mCurrentVCardEntry = null;
     }
 
+    @Override
     public void startProperty() {
         mCurrentProperty.clear();
     }
 
+    @Override
     public void endProperty() {
         mCurrentVCardEntry.addProperty(mCurrentProperty);
     }
     
+    @Override
     public void propertyName(String name) {
         mCurrentProperty.setPropertyName(name);
     }
 
+    @Override
     public void propertyGroup(String group) {
     }
 
+    @Override
     public void propertyParamType(String type) {
         if (mParamType != null) {
             Log.e(LOG_TAG, "propertyParamType() is called more than once " +
@@ -158,11 +167,16 @@
         mParamType = type;
     }
 
+    @Override
     public void propertyParamValue(String value) {
         if (mParamType == null) {
             // From vCard 2.1 specification. vCard 3.0 formally does not allow this case.
             mParamType = "TYPE";
         }
+        if (!VCardUtils.containsOnlyAlphaDigitHyphen(value)) {
+            value = encodeToSystemCharset(
+                    value, mSourceCharset, VCardConfig.DEFAULT_IMPORT_CHARSET);
+        }
         mCurrentProperty.addParameter(mParamType, value);
         mParamType = null;
     }
diff --git a/java/com/android/vcard/VCardUtils.java b/java/com/android/vcard/VCardUtils.java
index e8f3bf9..ae5d1ee 100644
--- a/java/com/android/vcard/VCardUtils.java
+++ b/java/com/android/vcard/VCardUtils.java
@@ -469,6 +469,31 @@
         return true;
     }
 
+    public static boolean containsOnlyWhiteSpaces(final String...values) {
+        if (values == null) {
+            return true;
+        }
+        return containsOnlyWhiteSpaces(Arrays.asList(values));
+    }
+
+    public static boolean containsOnlyWhiteSpaces(final Collection<String> values) {
+        if (values == null) {
+            return true;
+        }
+        for (final String str : values) {
+            if (TextUtils.isEmpty(str)) {
+                continue;
+            }
+            final int length = str.length();
+            for (int i = 0; i < length; i = str.offsetByCodePoints(i, 1)) {
+                if (!Character.isWhitespace(str.codePointAt(i))) {
+                    return false;
+                }
+            }
+        }
+        return true;
+    }
+
     /**
      * <p>
      * Returns true when the given String is categorized as "word" specified in vCard spec 2.1.
@@ -495,6 +520,47 @@
         return true;
     }
 
+    /**
+     * <P>
+     * Returns String available as parameter value in vCard 3.0.
+     * </P>
+     * <P>
+     * RFC 2426 requires vCard composer to quote parameter values when it contains
+     * semi-colon, for example (See RFC 2426 for more information).
+     * This method checks whether the given String can be used without quotes.
+     * </P>
+     * <P>
+     * Note: We remove DQUOTE silently for now.
+     * </P>
+     */
+    public static String toStringAvailableAsV30ParamValue(String value) {
+        if (TextUtils.isEmpty(value)) {
+            value = "";
+        }
+        final int asciiFirst = 0x20;
+        final int asciiLast = 0x7E;  // included
+        final StringBuilder builder = new StringBuilder();
+        final int length = value.length();
+        boolean needQuote = false;
+        for (int i = 0; i < length; i = value.offsetByCodePoints(i, 1)) {
+            final int codePoint = value.codePointAt(i);
+            if (codePoint < asciiFirst || codePoint == '"') {
+                // CTL characters and DQUOTE are never accepted. Remove them.
+                continue;
+            }
+            builder.appendCodePoint(codePoint);
+            if (codePoint == ':' || codePoint == ',' || codePoint == ' ') {
+                needQuote = true;
+            }
+        }
+
+        final String result = builder.toString();
+        return ((result.isEmpty() || VCardUtils.containsOnlyWhiteSpaces(result))
+                ? ""
+                : (needQuote ? ('"' + result + '"')
+                : result));
+    }
+
     public static String toHalfWidthString(final String orgString) {
         if (TextUtils.isEmpty(orgString)) {
             return null;
diff --git a/tests/res/raw/v30_multibyte_param.vcf b/tests/res/raw/v30_multibyte_param.vcf
new file mode 100644
index 0000000..cd200e5
--- /dev/null
+++ b/tests/res/raw/v30_multibyte_param.vcf
@@ -0,0 +1,5 @@
+BEGIN:VCARD

+VERSION:3.0

+N:F;G;M;;

+TEL;TYPE="่ดน":1

+END:VCARD

diff --git a/tests/src/com/android/vcard/tests/VCardExporterTests.java b/tests/src/com/android/vcard/tests/VCardExporterTests.java
index c960947..26b22c6 100644
--- a/tests/src/com/android/vcard/tests/VCardExporterTests.java
+++ b/tests/src/com/android/vcard/tests/VCardExporterTests.java
@@ -440,14 +440,26 @@
                 .put(Phone.TYPE, Phone.TYPE_CUSTOM)
                 .put(Phone.LABEL, "invalid");
         PropertyNodesVerifierElem elem = mVerifier.addPropertyNodesVerifierElemWithEmptyName();
-        elem.addExpectedNode("TEL", "1", new TypeSet("MODEM"))
-                .addExpectedNode("TEL", "2", new TypeSet("MSG"))
-                .addExpectedNode("TEL", "3", new TypeSet("BBS"))
-                .addExpectedNode("TEL", "4", new TypeSet("VIDEO"))
-                .addExpectedNode("TEL", "5", new TypeSet("VOICE"))
-                .addExpectedNode("TEL", "6", new TypeSet("CELL"))
-                .addExpectedNode("TEL", "7", new TypeSet("CELL"))
-                .addExpectedNode("TEL", "8", new TypeSet("X-invalid"));
+        if (VCardConfig.isVersion30(vcardType)) {
+            // vCard 3.0 accepts "invalid". Also stop using toUpper()
+            elem.addExpectedNode("TEL", "1", new TypeSet("Modem"))
+                    .addExpectedNode("TEL", "2", new TypeSet("MSG"))
+                    .addExpectedNode("TEL", "3", new TypeSet("BBS"))
+                    .addExpectedNode("TEL", "4", new TypeSet("VIDEO"))
+                    .addExpectedNode("TEL", "5", new TypeSet("VOICE"))
+                    .addExpectedNode("TEL", "6", new TypeSet("CELL"))
+                    .addExpectedNode("TEL", "7", new TypeSet("CELL"))
+                    .addExpectedNode("TEL", "8", new TypeSet("invalid"));
+        } else {
+            elem.addExpectedNode("TEL", "1", new TypeSet("MODEM"))
+                    .addExpectedNode("TEL", "2", new TypeSet("MSG"))
+                    .addExpectedNode("TEL", "3", new TypeSet("BBS"))
+                    .addExpectedNode("TEL", "4", new TypeSet("VIDEO"))
+                    .addExpectedNode("TEL", "5", new TypeSet("VOICE"))
+                    .addExpectedNode("TEL", "6", new TypeSet("CELL"))
+                    .addExpectedNode("TEL", "7", new TypeSet("CELL"))
+                    .addExpectedNode("TEL", "8", new TypeSet("X-invalid"));
+        }
     }
 
     public void testPhoneTypeHandlingV21() {
diff --git a/tests/src/com/android/vcard/tests/VCardImporterTests.java b/tests/src/com/android/vcard/tests/VCardImporterTests.java
index d93e06a..cdcce50 100644
--- a/tests/src/com/android/vcard/tests/VCardImporterTests.java
+++ b/tests/src/com/android/vcard/tests/VCardImporterTests.java
@@ -1024,6 +1024,28 @@
                 .put(Phone.NUMBER, "6101231234@pagersample.com");
     }
 
+    public void testMultiBytePropV30_Parse() {
+        mVerifier.initForImportTest(V30, R.raw.v30_multibyte_param);
+        mVerifier.addPropertyNodesVerifierElem()
+                .addExpectedNodeWithOrder("VERSION", "3.0")
+                .addExpectedNodeWithOrder("N", Arrays.asList("F", "G", "M", "", ""))
+                .addExpectedNodeWithOrder("TEL", "1", new TypeSet("\u8D39"));
+    }
+
+    public void testMultiBytePropV30() {
+        mVerifier.initForImportTest(V30, R.raw.v30_multibyte_param);
+        final ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem();
+        elem.addExpected(StructuredName.CONTENT_ITEM_TYPE)
+                .put(StructuredName.FAMILY_NAME, "F")
+                .put(StructuredName.MIDDLE_NAME, "M")
+                .put(StructuredName.GIVEN_NAME, "G")
+                .put(StructuredName.DISPLAY_NAME, "G M F");
+        elem.addExpected(Phone.CONTENT_ITEM_TYPE)
+                .put(Phone.TYPE, Phone.TYPE_CUSTOM)
+                .put(Phone.LABEL, "\u8D39")
+                .put(Phone.NUMBER, "1");
+    }
+
     /* TODO: implement this.
     public void testCommaSeparatedV30_Parse() {
         mVerifier.initForImportTest(V30, R.raw.v30_comma_separated);
diff --git a/tests/src/com/android/vcard/tests/VCardUtilsTests.java b/tests/src/com/android/vcard/tests/VCardUtilsTests.java
index 732009a..b5584fb 100644
--- a/tests/src/com/android/vcard/tests/VCardUtilsTests.java
+++ b/tests/src/com/android/vcard/tests/VCardUtilsTests.java
@@ -15,6 +15,8 @@
  */
 package com.android.vcard.tests;
 
+import android.text.TextUtils;
+
 import com.android.vcard.VCardUtils;
 
 import junit.framework.TestCase;
@@ -81,4 +83,38 @@
             assertFalse(VCardUtils.containsOnlyAlphaDigitHyphen(String.valueOf((char)i)));
         }
     }
+
+    public void testToStringAvailableAsV30ParamValue() {
+        // Smoke tests.
+        assertEquals("HOME", VCardUtils.toStringAvailableAsV30ParamValue("HOME"));
+        assertEquals("TEL", VCardUtils.toStringAvailableAsV30ParamValue("TEL"));
+        assertEquals("PAGER", VCardUtils.toStringAvailableAsV30ParamValue("PAGER"));
+
+        assertTrue(TextUtils.isEmpty(VCardUtils.toStringAvailableAsV30ParamValue("")));
+        assertTrue(TextUtils.isEmpty(VCardUtils.toStringAvailableAsV30ParamValue(null)));
+        assertTrue(TextUtils.isEmpty(VCardUtils.toStringAvailableAsV30ParamValue(" \t")));
+
+        // non-Ascii must be allowed
+        assertEquals("\u4E8B\u52D9\u6240",
+                VCardUtils.toStringAvailableAsV30ParamValue("\u4E8B\u52D9\u6240"));
+        // Reported as bug report.
+        assertEquals("\u8D39", VCardUtils.toStringAvailableAsV30ParamValue("\u8D39"));
+        assertEquals("\"comma,separated\"",
+                VCardUtils.toStringAvailableAsV30ParamValue("comma,separated"));
+        assertEquals("\"colon:aware\"",
+                VCardUtils.toStringAvailableAsV30ParamValue("colon:aware"));
+        // CTL characters.
+        assertEquals("CTLExample",
+                VCardUtils.toStringAvailableAsV30ParamValue("CTL\u0001Example"));
+        assertTrue(TextUtils.isEmpty(
+                VCardUtils.toStringAvailableAsV30ParamValue("\u0001\u0002\u0003")));
+        // DQUOTE must be removed.
+        assertEquals("quoted",
+                VCardUtils.toStringAvailableAsV30ParamValue("\"quoted\""));
+        // DQUOTE must be removed basically, but we should detect a space, which
+        // require us to use DQUOTE again.
+        // Right-side has one more illegal dquote to test quote-handle code thoroughly.
+        assertEquals("\"Already quoted\"",
+                VCardUtils.toStringAvailableAsV30ParamValue("\"Already quoted\"\""));
+    }
 }