Ensure each character is coverted by at most one LocaleSpan

This is a groundwork to attach LocaleSpan for committed text
in LatinIME.

This CL adds a utility method to ensure that a given range
of the text is coverted by at most one LocaleSpan.  Of course
it could be possible to allow a substring to be coverted by
multiple LocaleSpans at the same time, but ensuring uniqueness
for LocaleSpan is supposed to be a good starting point.

BUG: 16029304
Change-Id: Ic33a7178d0df1f05d3626aeb5773ec902254703f
diff --git a/java/src/com/android/inputmethod/compat/LocaleSpanCompatUtils.java b/java/src/com/android/inputmethod/compat/LocaleSpanCompatUtils.java
index 9676458..f411f18 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 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<Object> 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 a920373..319302c 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 @@
         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);
+        }
+    }
 }