From 205a99392806aed530ee0f8f0218084a34e5fdb9 Mon Sep 17 00:00:00 2001 From: Roozbeh Pournader Date: Thu, 8 Jun 2017 00:23:42 -0700 Subject: [PATCH] Internationalize InputFilter.AllCaps The new code support non-BMP characters, as well as locale-specific uppercasing and fine-grained span copying. The modern capitalization code in AllCapsTransformationMethod is moved to TextUtils now, so InputFilter.AllCaps can share it. Fixes: 37222101 Test: New CTS and core tests are added. Test: cts-tradefed run cts-dev --module CtsTextTestCases Test: adb shell am instrument -w -e package android.text com.android.frameworks.coretests/android.support.test.runner.AndroidJUnitRunner Change-Id: I021ff2a97a60396fb1b6e4940d91d3cd6ccb6196 --- api/current.txt | 1 + api/system-current.txt | 1 + api/test-current.txt | 1 + core/java/android/text/InputFilter.java | 126 +++++++++++++++--- core/java/android/text/TextUtils.java | 71 ++++++++++ .../method/AllCapsTransformationMethod.java | 67 +--------- .../src/android/text/TextUtilsTest.java | 92 ++++++++++++- 7 files changed, 274 insertions(+), 85 deletions(-) diff --git a/api/current.txt b/api/current.txt index a71c63ae598c2..a04f95bde91de 100644 --- a/api/current.txt +++ b/api/current.txt @@ -41102,6 +41102,7 @@ package android.text { public static class InputFilter.AllCaps implements android.text.InputFilter { ctor public InputFilter.AllCaps(); + ctor public InputFilter.AllCaps(java.util.Locale); method public java.lang.CharSequence filter(java.lang.CharSequence, int, int, android.text.Spanned, int, int); } diff --git a/api/system-current.txt b/api/system-current.txt index fbe210925ab3d..99208d5b37300 100644 --- a/api/system-current.txt +++ b/api/system-current.txt @@ -44647,6 +44647,7 @@ package android.text { public static class InputFilter.AllCaps implements android.text.InputFilter { ctor public InputFilter.AllCaps(); + ctor public InputFilter.AllCaps(java.util.Locale); method public java.lang.CharSequence filter(java.lang.CharSequence, int, int, android.text.Spanned, int, int); } diff --git a/api/test-current.txt b/api/test-current.txt index d586670b53f90..3f7977d9ed239 100644 --- a/api/test-current.txt +++ b/api/test-current.txt @@ -41319,6 +41319,7 @@ package android.text { public static class InputFilter.AllCaps implements android.text.InputFilter { ctor public InputFilter.AllCaps(); + ctor public InputFilter.AllCaps(java.util.Locale); method public java.lang.CharSequence filter(java.lang.CharSequence, int, int, android.text.Spanned, int, int); } diff --git a/core/java/android/text/InputFilter.java b/core/java/android/text/InputFilter.java index bff09a20e5950..d773158ed0cb4 100644 --- a/core/java/android/text/InputFilter.java +++ b/core/java/android/text/InputFilter.java @@ -16,6 +16,10 @@ package android.text; +import android.annotation.Nullable; + +import java.util.Locale; + /** * InputFilters can be attached to {@link Editable}s to constrain the * changes that can be made to them. @@ -33,40 +37,122 @@ public interface InputFilter * as this is what happens when you delete text. Also beware that * you should not attempt to make any changes to dest * from this method; you may only examine it for context. - * + * * Note: If source is an instance of {@link Spanned} or - * {@link Spannable}, the span objects in the source should be - * copied into the filtered result (i.e. the non-null return value). - * {@link TextUtils#copySpansFrom} can be used for convenience. + * {@link Spannable}, the span objects in the source should be + * copied into the filtered result (i.e. the non-null return value). + * {@link TextUtils#copySpansFrom} can be used for convenience if the + * span boundary indices would be remaining identical relative to the source. */ public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend); /** - * This filter will capitalize all the lower case letters that are added - * through edits. + * This filter will capitalize all the lowercase and titlecase letters that are added + * through edits. (Note that if there are no lowercase or titlecase letters in the input, the + * text would not be transformed, even if the result of capitalization of the string is + * different from the string.) */ public static class AllCaps implements InputFilter { + private final Locale mLocale; + + public AllCaps() { + mLocale = null; + } + + /** + * Constructs a locale-specific AllCaps filter, to make sure capitalization rules of that + * locale are used for transforming the sequence. + */ + public AllCaps(@Nullable Locale locale) { + mLocale = locale; + } + public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) { - for (int i = start; i < end; i++) { - if (Character.isLowerCase(source.charAt(i))) { - char[] v = new char[end - start]; - TextUtils.getChars(source, start, end, v, 0); - String s = new String(v).toUpperCase(); + final CharSequence wrapper = new CharSequenceWrapper(source, start, end); - if (source instanceof Spanned) { - SpannableString sp = new SpannableString(s); - TextUtils.copySpansFrom((Spanned) source, - start, end, null, sp, 0); - return sp; - } else { - return s; - } + boolean lowerOrTitleFound = false; + final int length = end - start; + for (int i = 0, cp; i < length; i += Character.charCount(cp)) { + // We access 'wrapper' instead of 'source' to make sure no code unit beyond 'end' is + // ever accessed. + cp = Character.codePointAt(wrapper, i); + if (Character.isLowerCase(cp) || Character.isTitleCase(cp)) { + lowerOrTitleFound = true; + break; } } + if (!lowerOrTitleFound) { + return null; // keep original + } - return null; // keep original + final boolean copySpans = source instanceof Spanned; + final CharSequence upper = TextUtils.toUpperCase(mLocale, wrapper, copySpans); + if (upper == wrapper) { + // Nothing was changed in the uppercasing operation. This is weird, since + // we had found at least one lowercase or titlecase character. But we can't + // do anything better than keeping the original in this case. + return null; // keep original + } + // Return a SpannableString or String for backward compatibility. + return copySpans ? new SpannableString(upper) : upper.toString(); + } + + private static class CharSequenceWrapper implements CharSequence, Spanned { + private final CharSequence mSource; + private final int mStart, mEnd; + private final int mLength; + + CharSequenceWrapper(CharSequence source, int start, int end) { + mSource = source; + mStart = start; + mEnd = end; + mLength = end - start; + } + + public int length() { + return mLength; + } + + public char charAt(int index) { + if (index < 0 || index >= mLength) { + throw new IndexOutOfBoundsException(); + } + return mSource.charAt(mStart + index); + } + + public CharSequence subSequence(int start, int end) { + if (start < 0 || end < 0 || end > mLength || start > end) { + throw new IndexOutOfBoundsException(); + } + return new CharSequenceWrapper(mSource, mStart + start, mStart + end); + } + + public String toString() { + return mSource.subSequence(mStart, mEnd).toString(); + } + + public T[] getSpans(int start, int end, Class type) { + return ((Spanned) mSource).getSpans(mStart + start, mStart + end, type); + } + + public int getSpanStart(Object tag) { + return ((Spanned) mSource).getSpanStart(tag) - mStart; + } + + public int getSpanEnd(Object tag) { + return ((Spanned) mSource).getSpanEnd(tag) - mStart; + } + + public int getSpanFlags(Object tag) { + return ((Spanned) mSource).getSpanFlags(tag); + } + + public int nextSpanTransition(int start, int limit, Class type) { + return ((Spanned) mSource).nextSpanTransition(mStart + start, mStart + limit, type) + - mStart; + } } } diff --git a/core/java/android/text/TextUtils.java b/core/java/android/text/TextUtils.java index 3baadd4c64c5a..440c88e8cabb1 100644 --- a/core/java/android/text/TextUtils.java +++ b/core/java/android/text/TextUtils.java @@ -23,6 +23,8 @@ import android.annotation.PluralsRes; import android.content.Context; import android.content.res.Resources; import android.icu.lang.UCharacter; +import android.icu.text.CaseMap; +import android.icu.text.Edits; import android.icu.util.ULocale; import android.os.Parcel; import android.os.Parcelable; @@ -1072,6 +1074,75 @@ public class TextUtils { } } + /** + * Transforms a CharSequences to uppercase, copying the sources spans and keeping them spans as + * much as possible close to their relative original places. In the case the the uppercase + * string is identical to the sources, the source itself is returned instead of being copied. + * + * If copySpans is set, source must be an instance of Spanned. + * + * {@hide} + */ + @NonNull + public static CharSequence toUpperCase(@Nullable Locale locale, @NonNull CharSequence source, + boolean copySpans) { + final Edits edits = new Edits(); + if (!copySpans) { // No spans. Just uppercase the characters. + final StringBuilder result = CaseMap.toUpper().apply( + locale, source, new StringBuilder(), edits); + return edits.hasChanges() ? result : source; + } + + final SpannableStringBuilder result = CaseMap.toUpper().apply( + locale, source, new SpannableStringBuilder(), edits); + if (!edits.hasChanges()) { + // No changes happened while capitalizing. We can return the source as it was. + return source; + } + + final Edits.Iterator iterator = edits.getFineIterator(); + final int sourceLength = source.length(); + final Spanned spanned = (Spanned) source; + final Object[] spans = spanned.getSpans(0, sourceLength, Object.class); + for (Object span : spans) { + final int sourceStart = spanned.getSpanStart(span); + final int sourceEnd = spanned.getSpanEnd(span); + final int flags = spanned.getSpanFlags(span); + // Make sure the indices are not at the end of the string, since in that case + // iterator.findSourceIndex() would fail. + final int destStart = sourceStart == sourceLength ? result.length() : + toUpperMapToDest(iterator, sourceStart); + final int destEnd = sourceEnd == sourceLength ? result.length() : + toUpperMapToDest(iterator, sourceEnd); + result.setSpan(span, destStart, destEnd, flags); + } + return result; + } + + // helper method for toUpperCase() + private static int toUpperMapToDest(Edits.Iterator iterator, int sourceIndex) { + // Guaranteed to succeed if sourceIndex < source.length(). + iterator.findSourceIndex(sourceIndex); + if (sourceIndex == iterator.sourceIndex()) { + return iterator.destinationIndex(); + } + // We handle the situation differently depending on if we are in the changed slice or an + // unchanged one: In an unchanged slice, we can find the exact location the span + // boundary was before and map there. + // + // But in a changed slice, we need to treat the whole destination slice as an atomic unit. + // We adjust the span boundary to the end of that slice to reduce of the chance of adjacent + // spans in the source overlapping in the result. (The choice for the end vs the beginning + // is somewhat arbitrary, but was taken because we except to see slightly more spans only + // affecting a base character compared to spans only affecting a combining character.) + if (iterator.hasChange()) { + return iterator.destinationIndex() + iterator.newLength(); + } else { + // Move the index 1:1 along with this unchanged piece of text. + return iterator.destinationIndex() + (sourceIndex - iterator.sourceIndex()); + } + } + public enum TruncateAt { START, MIDDLE, diff --git a/core/java/android/text/method/AllCapsTransformationMethod.java b/core/java/android/text/method/AllCapsTransformationMethod.java index 15f40d5121ef0..c807e7da4f1b1 100644 --- a/core/java/android/text/method/AllCapsTransformationMethod.java +++ b/core/java/android/text/method/AllCapsTransformationMethod.java @@ -15,12 +15,12 @@ */ package android.text.method; +import android.annotation.NonNull; +import android.annotation.Nullable; import android.content.Context; import android.graphics.Rect; -import android.icu.text.CaseMap; -import android.icu.text.Edits; -import android.text.SpannableStringBuilder; import android.text.Spanned; +import android.text.TextUtils; import android.util.Log; import android.view.View; import android.widget.TextView; @@ -38,12 +38,12 @@ public class AllCapsTransformationMethod implements TransformationMethod2 { private boolean mEnabled; private Locale mLocale; - public AllCapsTransformationMethod(Context context) { + public AllCapsTransformationMethod(@NonNull Context context) { mLocale = context.getResources().getConfiguration().getLocales().get(0); } @Override - public CharSequence getTransformation(CharSequence source, View view) { + public CharSequence getTransformation(@Nullable CharSequence source, View view) { if (!mEnabled) { Log.w(TAG, "Caller did not enable length changes; not transforming text"); return source; @@ -60,61 +60,8 @@ public class AllCapsTransformationMethod implements TransformationMethod2 { if (locale == null) { locale = mLocale; } - - if (!(source instanceof Spanned)) { // No spans - return CaseMap.toUpper().apply( - locale, source, new StringBuilder(), - null /* we don't need the edits */); - } - - final Edits edits = new Edits(); - final SpannableStringBuilder result = CaseMap.toUpper().apply( - locale, source, new SpannableStringBuilder(), edits); - if (!edits.hasChanges()) { - // No changes happened while capitalizing. We can return the source as it was. - return source; - } - - final Edits.Iterator iterator = edits.getFineIterator(); - final Spanned spanned = (Spanned) source; - final int sourceLength = source.length(); - final Object[] spans = spanned.getSpans(0, sourceLength, Object.class); - for (Object span : spans) { - final int sourceStart = spanned.getSpanStart(span); - final int sourceEnd = spanned.getSpanEnd(span); - final int flags = spanned.getSpanFlags(span); - // Make sure the indexes are not at the end of the string, since in that case - // iterator.findSourceIndex() would fail. - final int destStart = sourceStart == sourceLength ? result.length() : - mapToDest(iterator, sourceStart); - final int destEnd = sourceEnd == sourceLength ? result.length() : - mapToDest(iterator, sourceEnd); - result.setSpan(span, destStart, destEnd, flags); - } - return result; - } - - private static int mapToDest(Edits.Iterator iterator, int sourceIndex) { - // Guaranteed to succeed if sourceIndex < source.length(). - iterator.findSourceIndex(sourceIndex); - if (sourceIndex == iterator.sourceIndex()) { - return iterator.destinationIndex(); - } - // We handle the situation differently depending on if we are in the changed slice or an - // unchanged one: In an unchanged slice, we can find the exact location the span - // boundary was before and map there. - // - // But in a changed slice, we need to treat the whole destination slice as an atomic unit. - // We adjust the span boundary to the end of that slice to reduce of the chance of adjacent - // spans in the source overlapping in the result. (The choice for the end vs the beginning - // is somewhat arbitrary, but was taken because we except to see slightly more spans only - // affecting a base character compared to spans only affecting a combining character.) - if (iterator.hasChange()) { - return iterator.destinationIndex() + iterator.newLength(); - } else { - // Move the index 1:1 along with this unchanged piece of text. - return iterator.destinationIndex() + (sourceIndex - iterator.sourceIndex()); - } + final boolean copySpans = source instanceof Spanned; + return TextUtils.toUpperCase(locale, source, copySpans); } @Override diff --git a/core/tests/coretests/src/android/text/TextUtilsTest.java b/core/tests/coretests/src/android/text/TextUtilsTest.java index 312c4fb4b23f6..4cc648def549f 100644 --- a/core/tests/coretests/src/android/text/TextUtilsTest.java +++ b/core/tests/coretests/src/android/text/TextUtilsTest.java @@ -20,26 +20,28 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -import android.support.test.runner.AndroidJUnit4; -import com.google.android.collect.Lists; - import android.os.Parcel; import android.support.test.filters.LargeTest; import android.support.test.filters.SmallTest; +import android.support.test.runner.AndroidJUnit4; import android.test.MoreAsserts; import android.text.style.StyleSpan; import android.text.util.Rfc822Token; import android.text.util.Rfc822Tokenizer; import android.view.View; +import com.google.android.collect.Lists; + +import org.junit.Test; +import org.junit.runner.RunWith; + import java.util.ArrayList; import java.util.List; import java.util.Locale; -import org.junit.Test; -import org.junit.runner.RunWith; /** * TextUtilsTest tests {@link TextUtils}. @@ -580,4 +582,84 @@ public class TextUtilsTest { assertEquals(View.LAYOUT_DIRECTION_RTL, TextUtils.getLayoutDirectionFromLocale(Locale.forLanguageTag("tr-Arab"))); } + + @Test + public void testToUpperCase() { + { + final CharSequence result = TextUtils.toUpperCase(null, "abc", false); + assertEquals(StringBuilder.class, result.getClass()); + assertEquals("ABC", result.toString()); + } + { + final SpannableString str = new SpannableString("abc"); + Object span = new Object(); + str.setSpan(span, 1, 2, Spanned.SPAN_INCLUSIVE_INCLUSIVE); + + final CharSequence result = TextUtils.toUpperCase(null, str, true /* copySpans */); + assertEquals(SpannableStringBuilder.class, result.getClass()); + assertEquals("ABC", result.toString()); + final Spanned spanned = (Spanned) result; + final Object[] resultSpans = spanned.getSpans(0, result.length(), Object.class); + assertEquals(1, resultSpans.length); + assertSame(span, resultSpans[0]); + assertEquals(1, spanned.getSpanStart(span)); + assertEquals(2, spanned.getSpanEnd(span)); + assertEquals(Spanned.SPAN_INCLUSIVE_INCLUSIVE, spanned.getSpanFlags(span)); + } + { + final Locale turkish = new Locale("tr", "TR"); + final CharSequence result = TextUtils.toUpperCase(turkish, "i", false); + assertEquals(StringBuilder.class, result.getClass()); + assertEquals("İ", result.toString()); + } + { + final String str = "ABC"; + assertSame(str, TextUtils.toUpperCase(null, str, false)); + } + { + final SpannableString str = new SpannableString("ABC"); + str.setSpan(new Object(), 1, 2, Spanned.SPAN_INCLUSIVE_INCLUSIVE); + assertSame(str, TextUtils.toUpperCase(null, str, true /* copySpans */)); + } + } + + // Copied from cts/tests/tests/widget/src/android/widget/cts/TextViewTest.java and modified + // for the TextUtils.toUpperCase method. + @Test + public void testToUpperCase_SpansArePreserved() { + final Locale greek = new Locale("el", "GR"); + final String lowerString = "ι\u0301ριδα"; // ίριδα with first letter decomposed + final String upperString = "ΙΡΙΔΑ"; // uppercased + // expected lowercase to uppercase index map + final int[] indexMap = {0, 1, 1, 2, 3, 4, 5}; + final int flags = Spanned.SPAN_INCLUSIVE_INCLUSIVE; + + final Spannable source = new SpannableString(lowerString); + source.setSpan(new Object(), 0, 1, flags); + source.setSpan(new Object(), 1, 2, flags); + source.setSpan(new Object(), 2, 3, flags); + source.setSpan(new Object(), 3, 4, flags); + source.setSpan(new Object(), 4, 5, flags); + source.setSpan(new Object(), 5, 6, flags); + source.setSpan(new Object(), 0, 2, flags); + source.setSpan(new Object(), 1, 3, flags); + source.setSpan(new Object(), 2, 4, flags); + source.setSpan(new Object(), 0, 6, flags); + final Object[] sourceSpans = source.getSpans(0, source.length(), Object.class); + + final CharSequence uppercase = TextUtils.toUpperCase(greek, source, true /* copySpans */); + assertEquals(SpannableStringBuilder.class, uppercase.getClass()); + final Spanned result = (Spanned) uppercase; + + assertEquals(upperString, result.toString()); + final Object[] resultSpans = result.getSpans(0, result.length(), Object.class); + assertEquals(sourceSpans.length, resultSpans.length); + for (int i = 0; i < sourceSpans.length; i++) { + assertSame(sourceSpans[i], resultSpans[i]); + final Object span = sourceSpans[i]; + assertEquals(indexMap[source.getSpanStart(span)], result.getSpanStart(span)); + assertEquals(indexMap[source.getSpanEnd(span)], result.getSpanEnd(span)); + assertEquals(source.getSpanFlags(span), result.getSpanFlags(span)); + } + } }