diff --git a/java/src/com/android/inputmethod/compat/LocaleSpanCompatUtils.java b/java/src/com/android/inputmethod/compat/LocaleSpanCompatUtils.java index 967645878..f411f181b 100644 --- a/java/src/com/android/inputmethod/compat/LocaleSpanCompatUtils.java +++ b/java/src/com/android/inputmethod/compat/LocaleSpanCompatUtils.java @@ -16,30 +16,37 @@ package com.android.inputmethod.compat; +import android.text.Spannable; +import android.text.style.LocaleSpan; +import android.util.Log; + import com.android.inputmethod.annotations.UsedForTesting; -import com.android.inputmethod.compat.CompatUtils; import java.lang.reflect.Constructor; import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.Locale; @UsedForTesting public final class LocaleSpanCompatUtils { + private static final String TAG = LocaleSpanCompatUtils.class.getSimpleName(); + // Note that LocaleSpan(Locale locale) has been introduced in API level 17 // (Build.VERSION_CODE.JELLY_BEAN_MR1). - private static Class getLocalSpanClass() { + private static Class getLocaleSpanClass() { try { return Class.forName("android.text.style.LocaleSpan"); } catch (ClassNotFoundException e) { return null; } } + private static final Class LOCALE_SPAN_TYPE; private static final Constructor LOCALE_SPAN_CONSTRUCTOR; private static final Method LOCALE_SPAN_GET_LOCALE; static { - final Class localeSpanClass = getLocalSpanClass(); - LOCALE_SPAN_CONSTRUCTOR = CompatUtils.getConstructor(localeSpanClass, Locale.class); - LOCALE_SPAN_GET_LOCALE = CompatUtils.getMethod(localeSpanClass, "getLocale"); + LOCALE_SPAN_TYPE = getLocaleSpanClass(); + LOCALE_SPAN_CONSTRUCTOR = CompatUtils.getConstructor(LOCALE_SPAN_TYPE, Locale.class); + LOCALE_SPAN_GET_LOCALE = CompatUtils.getMethod(LOCALE_SPAN_TYPE, "getLocale"); } @UsedForTesting @@ -56,4 +63,162 @@ public final class LocaleSpanCompatUtils { public static Locale getLocaleFromLocaleSpan(final Object localeSpan) { return (Locale) CompatUtils.invoke(localeSpan, null, LOCALE_SPAN_GET_LOCALE); } + + /** + * Ensures that the specified range is covered with only one {@link LocaleSpan} with the given + * locale. If the region is already covered by one or more {@link LocaleSpan}, their ranges are + * updated so that each character has only one locale. + * @param spannable the spannable object to be updated. + * @param start the start index from which {@link LocaleSpan} is attached (inclusive). + * @param end the end index to which {@link LocaleSpan} is attached (exclusive). + * @param locale the locale to be attached to the specified range. + */ + @UsedForTesting + public static void updateLocaleSpan(final Spannable spannable, final int start, + final int end, final Locale locale) { + if (end < start) { + Log.e(TAG, "Invalid range: start=" + start + " end=" + end); + return; + } + if (!isLocaleSpanAvailable()) { + return; + } + // A brief summary of our strategy; + // 1. Enumerate all LocaleSpans between [start - 1, end + 1]. + // 2. For each LocaleSpan S: + // - Update the range of S so as not to cover [start, end] if S doesn't have the + // expected locale. + // - Mark S as "to be merged" if S has the expected locale. + // 3. Merge all the LocaleSpans that are marked as "to be merged" into one LocaleSpan. + // If no appropriate span is found, create a new one with newLocaleSpan method. + final int searchStart = Math.max(start - 1, 0); + final int searchEnd = Math.min(end + 1, spannable.length()); + // LocaleSpans found in the target range. See the step 1 in the above comment. + final Object[] existingLocaleSpans = spannable.getSpans(searchStart, searchEnd, + LOCALE_SPAN_TYPE); + // LocaleSpans that are marked as "to be merged". See the step 2 in the above comment. + final ArrayList existingLocaleSpansToBeMerged = new ArrayList<>(); + boolean isStartExclusive = true; + boolean isEndExclusive = true; + int newStart = start; + int newEnd = end; + for (final Object existingLocaleSpan : existingLocaleSpans) { + final Locale attachedLocale = getLocaleFromLocaleSpan(existingLocaleSpan); + if (!locale.equals(attachedLocale)) { + // This LocaleSpan does not have the expected locale. Update its range if it has + // an intersection with the range [start, end] (the first case of the step 2 in the + // above comment). + removeLocaleSpanFromRange(existingLocaleSpan, spannable, start, end); + continue; + } + final int spanStart = spannable.getSpanStart(existingLocaleSpan); + final int spanEnd = spannable.getSpanEnd(existingLocaleSpan); + if (spanEnd < spanStart) { + Log.e(TAG, "Invalid span: spanStart=" + spanStart + " spanEnd=" + spanEnd); + continue; + } + if (spanEnd < start || end < spanStart) { + // No intersection found. + continue; + } + + // Here existingLocaleSpan has the expected locale and an intersection with the + // range [start, end] (the second case of the the step 2 in the above comment). + final int spanFlag = spannable.getSpanFlags(existingLocaleSpan); + if (spanStart < newStart) { + newStart = spanStart; + isStartExclusive = ((spanFlag & Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) == + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (newEnd < spanEnd) { + newEnd = spanEnd; + isEndExclusive = ((spanFlag & Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) == + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + existingLocaleSpansToBeMerged.add(existingLocaleSpan); + } + + int originalLocaleSpanFlag = 0; + Object localeSpan = null; + if (existingLocaleSpansToBeMerged.isEmpty()) { + // If there is no LocaleSpan that is marked as to be merged, create a new one. + localeSpan = newLocaleSpan(locale); + } else { + // Reuse the first LocaleSpan to avoid unnecessary object instantiation. + localeSpan = existingLocaleSpansToBeMerged.get(0); + originalLocaleSpanFlag = spannable.getSpanFlags(localeSpan); + // No need to keep other instances. + for (int i = 1; i < existingLocaleSpansToBeMerged.size(); ++i) { + spannable.removeSpan(existingLocaleSpansToBeMerged.get(i)); + } + } + final int localeSpanFlag = getSpanFlag(originalLocaleSpanFlag, isStartExclusive, + isEndExclusive); + spannable.setSpan(localeSpan, newStart, newEnd, localeSpanFlag); + } + + private static void removeLocaleSpanFromRange(final Object localeSpan, + final Spannable spannable, final int removeStart, final int removeEnd) { + if (!isLocaleSpanAvailable()) { + return; + } + final int spanStart = spannable.getSpanStart(localeSpan); + final int spanEnd = spannable.getSpanEnd(localeSpan); + if (spanStart > spanEnd) { + Log.e(TAG, "Invalid span: spanStart=" + spanStart + " spanEnd=" + spanEnd); + return; + } + if (spanEnd < removeStart) { + // spanStart < spanEnd < removeStart < removeEnd + return; + } + if (removeEnd < spanStart) { + // spanStart < removeEnd < spanStart < spanEnd + return; + } + final int spanFlags = spannable.getSpanFlags(localeSpan); + if (spanStart < removeStart) { + if (removeEnd < spanEnd) { + // spanStart < removeStart < removeEnd < spanEnd + final Locale locale = getLocaleFromLocaleSpan(localeSpan); + spannable.setSpan(localeSpan, spanStart, removeStart, spanFlags); + final Object attionalLocaleSpan = newLocaleSpan(locale); + spannable.setSpan(attionalLocaleSpan, removeEnd, spanEnd, spanFlags); + return; + } + // spanStart < removeStart < spanEnd <= removeEnd + spannable.setSpan(localeSpan, spanStart, removeStart, spanFlags); + return; + } + if (removeEnd < spanEnd) { + // removeStart <= spanStart < removeEnd < spanEnd + spannable.setSpan(localeSpan, removeEnd, spanEnd, spanFlags); + return; + } + // removeStart <= spanStart < spanEnd < removeEnd + spannable.removeSpan(localeSpan); + } + + private static int getSpanFlag(final int originalFlag, + final boolean isStartExclusive, final boolean isEndExclusive) { + return (originalFlag & ~Spannable.SPAN_POINT_MARK_MASK) | + getSpanPointMarkFlag(isStartExclusive, isEndExclusive); + } + + private static int getSpanPointMarkFlag(final boolean isStartExclusive, + final boolean isEndExclusive) { + if (isStartExclusive) { + if (isEndExclusive) { + return Spannable.SPAN_EXCLUSIVE_EXCLUSIVE; + } else { + return Spannable.SPAN_EXCLUSIVE_INCLUSIVE; + } + } else { + if (isEndExclusive) { + return Spannable.SPAN_INCLUSIVE_EXCLUSIVE; + } else { + return Spannable.SPAN_INCLUSIVE_INCLUSIVE; + } + } + } } diff --git a/tests/src/com/android/inputmethod/compat/LocaleSpanCompatUtilsTests.java b/tests/src/com/android/inputmethod/compat/LocaleSpanCompatUtilsTests.java index a920373d4..319302c71 100644 --- a/tests/src/com/android/inputmethod/compat/LocaleSpanCompatUtilsTests.java +++ b/tests/src/com/android/inputmethod/compat/LocaleSpanCompatUtilsTests.java @@ -16,9 +16,14 @@ package com.android.inputmethod.compat; +import android.graphics.Typeface; import android.os.Build; import android.test.AndroidTestCase; import android.test.suitebuilder.annotation.SmallTest; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.StyleSpan; import java.util.Locale; @@ -35,4 +40,173 @@ public class LocaleSpanCompatUtilsTests extends AndroidTestCase { assertEquals(Locale.JAPANESE, LocaleSpanCompatUtils.getLocaleFromLocaleSpan(japaneseLocaleSpan)); } + + private static void assertLocaleSpan(final Spanned spanned, final int index, + final int expectedStart, final int expectedEnd, + final Locale expectedLocale, final int expectedSpanFlags) { + final Object span = spanned.getSpans(0, spanned.length(), Object.class)[index]; + assertEquals(expectedLocale, LocaleSpanCompatUtils.getLocaleFromLocaleSpan(span)); + assertEquals(expectedStart, spanned.getSpanStart(span)); + assertEquals(expectedEnd, spanned.getSpanEnd(span)); + assertEquals(expectedSpanFlags, spanned.getSpanFlags(span)); + } + + private static void assertSpanEquals(final Object expectedSpan, final Spanned spanned, + final int index) { + final Object[] spans = spanned.getSpans(0, spanned.length(), Object.class); + assertEquals(expectedSpan, spans[index]); + } + + private static void assertSpanCount(final int expectedCount, final Spanned spanned) { + final Object[] spans = spanned.getSpans(0, spanned.length(), Object.class); + assertEquals(expectedCount, spans.length); + } + + public void testUpdateLocaleSpan() { + if (!LocaleSpanCompatUtils.isLocaleSpanAvailable()) { + return; + } + + // Test if the simplest case works. + { + final SpannableString text = new SpannableString("0123456789"); + LocaleSpanCompatUtils.updateLocaleSpan(text, 1, 5, Locale.JAPANESE); + assertSpanCount(1, text); + assertLocaleSpan(text, 0, 1, 5, Locale.JAPANESE, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + // Test if only LocaleSpans are updated. + { + final SpannableString text = new SpannableString("0123456789"); + final StyleSpan styleSpan = new StyleSpan(Typeface.BOLD); + text.setSpan(styleSpan, 0, 7, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + LocaleSpanCompatUtils.updateLocaleSpan(text, 1, 5, Locale.JAPANESE); + assertSpanCount(2, text); + assertSpanEquals(styleSpan, text, 0); + assertLocaleSpan(text, 1, 1, 5, Locale.JAPANESE, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + // Test if two jointed spans are merged into one span. + { + final SpannableString text = new SpannableString("0123456789"); + text.setSpan(LocaleSpanCompatUtils.newLocaleSpan(Locale.JAPANESE), 1, 3, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + LocaleSpanCompatUtils.updateLocaleSpan(text, 3, 5, Locale.JAPANESE); + assertSpanCount(1, text); + assertLocaleSpan(text, 0, 1, 5, Locale.JAPANESE, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + // Test if two overlapped spans are merged into one span. + { + final SpannableString text = new SpannableString("0123456789"); + text.setSpan(LocaleSpanCompatUtils.newLocaleSpan(Locale.JAPANESE), 1, 4, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + LocaleSpanCompatUtils.updateLocaleSpan(text, 3, 5, Locale.JAPANESE); + assertSpanCount(1, text); + assertLocaleSpan(text, 0, 1, 5, Locale.JAPANESE, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + // Test if three overlapped spans are merged into one span. + { + final SpannableString text = new SpannableString("0123456789"); + text.setSpan(LocaleSpanCompatUtils.newLocaleSpan(Locale.JAPANESE), 1, 4, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(LocaleSpanCompatUtils.newLocaleSpan(Locale.JAPANESE), 5, 6, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + LocaleSpanCompatUtils.updateLocaleSpan(text, 2, 8, Locale.JAPANESE); + assertSpanCount(1, text); + assertLocaleSpan(text, 0, 1, 8, Locale.JAPANESE, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + // Test if disjoint spans remain disjoint. + { + final SpannableString text = new SpannableString("0123456789"); + text.setSpan(LocaleSpanCompatUtils.newLocaleSpan(Locale.JAPANESE), 1, 3, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(LocaleSpanCompatUtils.newLocaleSpan(Locale.JAPANESE), 5, 6, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + LocaleSpanCompatUtils.updateLocaleSpan(text, 8, 9, Locale.JAPANESE); + assertSpanCount(3, text); + assertLocaleSpan(text, 0, 1, 3, Locale.JAPANESE, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + assertLocaleSpan(text, 1, 5, 6, Locale.JAPANESE, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + assertLocaleSpan(text, 2, 8, 9, Locale.JAPANESE, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + // Test if existing span flags are preserved during merge. + { + final SpannableString text = new SpannableString("0123456789"); + text.setSpan(LocaleSpanCompatUtils.newLocaleSpan(Locale.JAPANESE), 1, 5, + Spannable.SPAN_INCLUSIVE_INCLUSIVE | Spannable.SPAN_INTERMEDIATE); + LocaleSpanCompatUtils.updateLocaleSpan(text, 3, 4, Locale.JAPANESE); + assertSpanCount(1, text); + assertLocaleSpan(text, 0, 1, 5, Locale.JAPANESE, + Spannable.SPAN_INCLUSIVE_INCLUSIVE | Spannable.SPAN_INTERMEDIATE); + } + + // Test if existing span flags are preserved even when partially overlapped (leading edge). + { + final SpannableString text = new SpannableString("0123456789"); + text.setSpan(LocaleSpanCompatUtils.newLocaleSpan(Locale.JAPANESE), 1, 5, + Spannable.SPAN_INCLUSIVE_INCLUSIVE | Spannable.SPAN_INTERMEDIATE); + LocaleSpanCompatUtils.updateLocaleSpan(text, 3, 7, Locale.JAPANESE); + assertSpanCount(1, text); + assertLocaleSpan(text, 0, 1, 7, Locale.JAPANESE, + Spannable.SPAN_INCLUSIVE_EXCLUSIVE | Spannable.SPAN_INTERMEDIATE); + } + + // Test if existing span flags are preserved even when partially overlapped (trailing edge). + { + final SpannableString text = new SpannableString("0123456789"); + text.setSpan(LocaleSpanCompatUtils.newLocaleSpan(Locale.JAPANESE), 3, 7, + Spannable.SPAN_INCLUSIVE_INCLUSIVE | Spannable.SPAN_INTERMEDIATE); + LocaleSpanCompatUtils.updateLocaleSpan(text, 1, 5, Locale.JAPANESE); + assertSpanCount(1, text); + assertLocaleSpan(text, 0, 1, 7, Locale.JAPANESE, + Spannable.SPAN_EXCLUSIVE_INCLUSIVE | Spannable.SPAN_INTERMEDIATE); + } + + // Test if existing locale span will be removed when the locale doesn't match. + { + final SpannableString text = new SpannableString("0123456789"); + text.setSpan(LocaleSpanCompatUtils.newLocaleSpan(Locale.ENGLISH), 3, 5, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + LocaleSpanCompatUtils.updateLocaleSpan(text, 1, 7, Locale.JAPANESE); + assertSpanCount(1, text); + assertLocaleSpan(text, 0, 1, 7, Locale.JAPANESE, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + // Test if existing locale span will be removed when the locale doesn't match. (case 2) + { + final SpannableString text = new SpannableString("0123456789"); + text.setSpan(LocaleSpanCompatUtils.newLocaleSpan(Locale.ENGLISH), 3, 7, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + LocaleSpanCompatUtils.updateLocaleSpan(text, 5, 6, Locale.JAPANESE); + assertSpanCount(3, text); + assertLocaleSpan(text, 0, 3, 5, Locale.ENGLISH, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + assertLocaleSpan(text, 1, 6, 7, Locale.ENGLISH, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + assertLocaleSpan(text, 2, 5, 6, Locale.JAPANESE, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + // Test if existing locale span will be removed when the locale doesn't match. (case 3) + { + final SpannableString text = new SpannableString("0123456789"); + text.setSpan(LocaleSpanCompatUtils.newLocaleSpan(Locale.ENGLISH), 3, 7, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + LocaleSpanCompatUtils.updateLocaleSpan(text, 2, 5, Locale.JAPANESE); + assertSpanCount(2, text); + assertLocaleSpan(text, 0, 5, 7, Locale.ENGLISH, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + assertLocaleSpan(text, 1, 2, 5, Locale.JAPANESE, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + // Test if existing locale span will be removed when the locale doesn't match. (case 3) + { + final SpannableString text = new SpannableString("0123456789"); + text.setSpan(LocaleSpanCompatUtils.newLocaleSpan(Locale.ENGLISH), 3, 7, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + LocaleSpanCompatUtils.updateLocaleSpan(text, 5, 8, Locale.JAPANESE); + assertSpanCount(2, text); + assertLocaleSpan(text, 0, 3, 5, Locale.ENGLISH, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + assertLocaleSpan(text, 1, 5, 8, Locale.JAPANESE, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } }