Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit ab421cab authored by Seigo Nonaka's avatar Seigo Nonaka Committed by android-build-merger
Browse files

Merge "Improve backspace for emoji and variation sequences." into nyc-dev

am: 3c6e3081

* commit '3c6e3081':
  Improve backspace for emoji and variation sequences.
parents ec850621 3c6e3081
Loading
Loading
Loading
Loading
+72 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2016 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package android.text;

import java.util.Arrays;

/**
 * An utility class for Emoji.
 * @hide
 */
public class Emoji {
    // See http://www.unicode.org/Public/emoji/2.0//emoji-data.txt
    private static int[] EMOJI_MODIFIER_BASE = {
        0x261D, 0x26F9, 0x270A, 0x270B, 0x270C, 0x270D, 0x1F385, 0x1F3C3, 0x1F3C4, 0x1F3CA,
        0x1F3CB, 0x1F442, 0x1F443, 0x1F446, 0x1F447, 0x1F448, 0x1F449, 0x1F44A, 0x1F44B, 0x1F44C,
        0x1F44D, 0x1F44E, 0x1F44F, 0x1F450, 0x1F466, 0x1F467, 0x1F468, 0x1F469, 0x1F46E, 0x1F470,
        0x1F471, 0x1F472, 0x1F473, 0x1F474, 0x1F475, 0x1F476, 0x1F474, 0x1F478, 0x1F47C, 0x1F481,
        0x1F482, 0x1F483, 0x1F485, 0x1F486, 0x1F487, 0x1F4AA, 0x1F575, 0x1F590, 0x1F595, 0x1F596,
        0x1F645, 0x1F646, 0x1F647, 0x1F64B, 0x1F64C, 0x1F64D, 0x1F64E, 0x1F64F, 0x1F6A3, 0x1F6B4,
        0x1F6B5, 0x1F6B6, 0x1F6C0, 0x1F918
    };

    // See http://www.unicode.org/emoji/charts/emoji-zwj-sequences.html
    private static int[] ZWJ_EMOJI = {
        0x2764, 0x1F441, 0x1F466, 0x1F467, 0x1F468, 0x1F469, 0x1F48B, 0x1F5E8
    };

    public static int COMBINING_ENCLOSING_KEYCAP = 0x20E3;

    public static int ZERO_WIDTH_JOINER = 0x200D;

    public static int VARIATION_SELECTOR_16 = 0xFE0F;

    // Returns true if the given code point is regional indicator symbol.
    public static boolean isRegionalIndicatorSymbol(int codepoint) {
        return 0x1F1E6 <= codepoint && codepoint <= 0x1F1FF;
    }

    // Returns true if the given code point is emoji modifier.
    public static boolean isEmojiModifier(int codepoint) {
        return 0x1F3FB <= codepoint && codepoint <= 0x1F3FF;
    }

    // Returns true if the given code point is emoji modifier base.
    public static boolean isEmojiModifierBase(int codePoint) {
        return Arrays.binarySearch(EMOJI_MODIFIER_BASE, codePoint) >= 0;
    }

    // Returns true if the character appears before or after zwj in a zwj emoji sequence.
    public static boolean isZwjEmoji(int codePoint) {
        return Arrays.binarySearch(ZWJ_EMOJI, codePoint) >= 0;
    }

    // Returns true if the character can be a base character of COMBINING ENCLOSING KEYCAP.
    public static boolean isKeycapBase(int codePoint) {
        return ('0' <= codePoint && codePoint <= '9') || codePoint == '#' || codePoint == '*';
    }
}
+215 −2
Original line number Diff line number Diff line
@@ -16,13 +16,19 @@

package android.text.method;

import android.icu.lang.UCharacter;
import android.icu.lang.UProperty;
import android.view.KeyEvent;
import android.view.View;
import android.text.*;
import android.text.method.TextKeyListener.Capitalize;
import android.text.style.ReplacementSpan;
import android.widget.TextView;

import java.text.BreakIterator;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;

/**
 * Abstract base class for key listeners.
@@ -63,6 +69,213 @@ public abstract class BaseKeyListener extends MetaKeyKeyListener
        return backspaceOrForwardDelete(view, content, keyCode, event, true);
    }

    // Returns true if the given code point is a variation selector.
    private static boolean isVariationSelector(int codepoint) {
        return UCharacter.hasBinaryProperty(codepoint, UProperty.VARIATION_SELECTOR);
    }

    // Returns the offset of the replacement span edge if the offset is inside of the replacement
    // span.  Otherwise, does nothing and returns the input offset value.
    private static int adjustReplacementSpan(CharSequence text, int offset, boolean moveToStart) {
        if (!(text instanceof Spanned)) {
            return offset;
        }

        ReplacementSpan[] spans = ((Spanned) text).getSpans(offset, offset, ReplacementSpan.class);
        for (int i = 0; i < spans.length; i++) {
            final int start = ((Spanned) text).getSpanStart(spans[i]);
            final int end = ((Spanned) text).getSpanEnd(spans[i]);

            if (start < offset && end > offset) {
                offset = moveToStart ? start : end;
            }
        }
        return offset;
    }

    // Returns the start offset to be deleted by a backspace key from the given offset.
    private static int getOffsetForBackspaceKey(CharSequence text, int offset) {
        if (offset <= 1) {
            return 0;
        }

        // Initial state
        final int STATE_START = 0;

        // The offset is immediately before a KEYCAP.
        final int STATE_BEFORE_KEYCAP = 1;
        // The offset is immediately before a variation selector and a KEYCAP.
        final int STATE_BEFORE_VS_AND_KEYCAP = 2;

        // The offset is immediately before an emoji modifier.
        final int STATE_BEFORE_EMOJI_MODIFIER = 3;
        // The offset is immediately before a variation selector and an emoji modifier.
        final int STATE_BEFORE_VS_AND_EMOJI_MODIFIER = 4;

        // The offset is immediately before a variation selector.
        final int STATE_BEFORE_VS = 5;

        // The offset is immediately before a ZWJ emoji.
        final int STATE_BEFORE_ZWJ_EMOJI = 6;
        // The offset is immediately before a ZWJ that were seen before a ZWJ emoji.
        final int STATE_BEFORE_ZWJ = 7;
        // The offset is immediately before a variation selector and a ZWJ that were seen before a
        // ZWJ emoji.
        final int STATE_BEFORE_VS_AND_ZWJ = 8;

        // The number of following RIS code points is odd.
        final int STATE_ODD_NUMBERED_RIS = 9;
        // The number of following RIS code points is even.
        final int STATE_EVEN_NUMBERED_RIS = 10;

        // The state machine has been stopped.
        final int STATE_FINISHED = 11;

        int deleteCharCount = 0;  // Char count to be deleted by backspace.
        int lastSeenVSCharCount = 0;  // Char count of previous variation selector.

        int state = STATE_START;

        int tmpOffset = offset;
        do {
            final int codePoint = Character.codePointBefore(text, tmpOffset);
            tmpOffset -= Character.charCount(codePoint);

            switch (state) {
                case STATE_START:
                    deleteCharCount = Character.charCount(codePoint);
                    if (isVariationSelector(codePoint)) {
                        state = STATE_BEFORE_VS;
                    } else if (Emoji.isZwjEmoji(codePoint)) {
                        state = STATE_BEFORE_ZWJ_EMOJI;
                    } else if (Emoji.isRegionalIndicatorSymbol(codePoint)) {
                        state = STATE_ODD_NUMBERED_RIS;
                    } else if (Emoji.isEmojiModifier(codePoint)) {
                        state = STATE_BEFORE_EMOJI_MODIFIER;
                    } else if (codePoint == Emoji.COMBINING_ENCLOSING_KEYCAP) {
                        state = STATE_BEFORE_KEYCAP;
                    } else {
                        state = STATE_FINISHED;
                    }
                    break;
                case STATE_ODD_NUMBERED_RIS:
                    if (Emoji.isRegionalIndicatorSymbol(codePoint)) {
                        deleteCharCount += 2; /* Char count of RIS */
                        state = STATE_EVEN_NUMBERED_RIS;
                    } else {
                        state = STATE_FINISHED;
                    }
                    break;
                case STATE_EVEN_NUMBERED_RIS:
                    if (Emoji.isRegionalIndicatorSymbol(codePoint)) {
                        deleteCharCount -= 2; /* Char count of RIS */
                        state = STATE_ODD_NUMBERED_RIS;
                    } else {
                        state = STATE_FINISHED;
                    }
                    break;
                case STATE_BEFORE_KEYCAP:
                    if (isVariationSelector(codePoint)) {
                        lastSeenVSCharCount = Character.charCount(codePoint);
                        state = STATE_BEFORE_VS_AND_KEYCAP;
                        break;
                    }

                    if (Emoji.isKeycapBase(codePoint)) {
                        deleteCharCount += Character.charCount(codePoint);
                    }
                    state = STATE_FINISHED;
                    break;
                case STATE_BEFORE_VS_AND_KEYCAP:
                    if (Emoji.isKeycapBase(codePoint)) {
                        deleteCharCount += lastSeenVSCharCount + Character.charCount(codePoint);
                    }
                    state = STATE_FINISHED;
                    break;
                case STATE_BEFORE_EMOJI_MODIFIER:
                    if (isVariationSelector(codePoint)) {
                        lastSeenVSCharCount = Character.charCount(codePoint);
                        state = STATE_BEFORE_VS_AND_EMOJI_MODIFIER;
                        break;
                    } else if (Emoji.isEmojiModifierBase(codePoint)) {
                        deleteCharCount += Character.charCount(codePoint);
                    }
                    state = STATE_FINISHED;
                    break;
                case STATE_BEFORE_VS_AND_EMOJI_MODIFIER:
                    if (Emoji.isEmojiModifierBase(codePoint)) {
                        deleteCharCount += lastSeenVSCharCount + Character.charCount(codePoint);
                    }
                    state = STATE_FINISHED;
                    break;
                case STATE_BEFORE_VS:
                    if (Emoji.isZwjEmoji(codePoint)) {
                        deleteCharCount += Character.charCount(codePoint);
                        state = STATE_BEFORE_ZWJ_EMOJI;
                        break;
                    }

                    if (!isVariationSelector(codePoint) &&
                            UCharacter.getCombiningClass(codePoint) == 0) {
                        deleteCharCount += Character.charCount(codePoint);
                    }
                    state = STATE_FINISHED;
                    break;
                case STATE_BEFORE_ZWJ_EMOJI:
                    if (codePoint == Emoji.ZERO_WIDTH_JOINER) {
                        state = STATE_BEFORE_ZWJ;
                    } else {
                        state = STATE_FINISHED;
                    }
                    break;
                case STATE_BEFORE_ZWJ:
                    if (Emoji.isZwjEmoji(codePoint)) {
                        deleteCharCount += Character.charCount(codePoint) + 1;  // +1 for ZWJ.
                        state = STATE_BEFORE_ZWJ_EMOJI;
                    } else if (isVariationSelector(codePoint)) {
                        lastSeenVSCharCount = Character.charCount(codePoint);
                        state = STATE_BEFORE_VS_AND_ZWJ;
                    } else {
                        state = STATE_FINISHED;
                    }
                    break;
                case STATE_BEFORE_VS_AND_ZWJ:
                    if (Emoji.isZwjEmoji(codePoint)) {
                        // +1 for ZWJ.
                        deleteCharCount += lastSeenVSCharCount + 1 + Character.charCount(codePoint);
                        lastSeenVSCharCount = 0;
                        state = STATE_BEFORE_ZWJ_EMOJI;
                    } else {
                        state = STATE_FINISHED;
                    }
                    break;
                default:
                    throw new IllegalArgumentException("state " + state + " is unknown");
            }
        } while (tmpOffset > 0 && state != STATE_FINISHED);

        return adjustReplacementSpan(text, offset - deleteCharCount, true /* move to the start */);
    }

    // Returns the end offset to be deleted by a forward delete key from the given offset.
    private static int getOffsetForForwardDeleteKey(CharSequence text, int offset) {
        final int len = text.length();

        if (offset >= len - 1) {
            return len;
        }

        int codePoint = Character.codePointAt(text, offset);
        offset += Character.charCount(codePoint);
        if (offset == len) {
            return len;
        }

        // TODO: Handle emoji, combining chars, etc.

        return adjustReplacementSpan(text, offset, false /* move to the end */);
    }

    private boolean backspaceOrForwardDelete(View view, Editable content, int keyCode,
            KeyEvent event, boolean isForwardDelete) {
        // Ensure the key event does not have modifiers except ALT or SHIFT or CTRL.
@@ -98,9 +311,9 @@ public abstract class BaseKeyListener extends MetaKeyKeyListener
        final int start = Selection.getSelectionEnd(content);
        final int end;
        if (isForwardDelete) {
            end = TextUtils.getOffsetAfter(content, start);
            end = getOffsetForForwardDeleteKey(content, start);
        } else {
            end = TextUtils.getOffsetBefore(content, start);
            end = getOffsetForBackspaceKey(content, start);
        }
        if (start != end) {
            content.delete(Math.min(start, end), Math.max(start, end));
+588 −0

File added.

Preview size limit exceeded, changes collapsed.

+187 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2016 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package android.text.method;

import android.graphics.Canvas;
import android.graphics.Paint;
import android.text.Editable;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.style.ReplacementSpan;

import junit.framework.Assert;

/**
 * Represents an editor state.
 *
 * The editor state can be specified by following string format.
 * - Components are separated by space(U+0020).
 * - Single-quoted string for printable ASCII characters, e.g. 'a', '123'.
 * - U+XXXX form can be used for a Unicode code point.
 * - Components inside '[' and ']' are in selection.
 * - Components inside '(' and ')' are in ReplacementSpan.
 * - '|' is for specifying cursor position.
 *
 * Selection and cursor can not be specified at the same time.
 *
 * Example:
 *   - "'Hello,' | U+0020 'world!'" means "Hello, world!" is displayed and the cursor position
 *     is 6.
 *   - "'abc' [ 'def' ] 'ghi'" means "abcdefghi" is displayed and "def" is selected.
 *   - "U+1F441 | ( U+1F441 U+1F441 )" means three U+1F441 characters are displayed and
 *     ReplacementSpan is set from offset 2 to 6.
 */
public class EditorState {
    private static final String REPLACEMENT_SPAN_START = "(";
    private static final String REPLACEMENT_SPAN_END = ")";
    private static final String SELECTION_START = "[";
    private static final String SELECTION_END = "]";
    private static final String CURSOR = "|";

    public Editable mText;
    public int mSelectionStart = -1;
    public int mSelectionEnd = -1;

    public EditorState() {
    }

    /**
     * A mocked {@link android.text.style.ReplacementSpan} for testing purpose.
     */
    private static class MockReplacementSpan extends ReplacementSpan {
        public int getSize(Paint paint, CharSequence text, int start, int end,
                Paint.FontMetricsInt fm) {
            return 0;
        }
        public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top,
                int y, int bottom, Paint paint) {
        }
    }

    // Returns true if the code point is ASCII and graph.
    private boolean isGraphicAscii(int codePoint) {
        return 0x20 < codePoint && codePoint < 0x7F;
    }

    // Setup editor state with string. Please see class description for string format.
    public void setByString(String string) {
        final StringBuilder sb = new StringBuilder();
        int replacementSpanStart = -1;
        int replacementSpanEnd = -1;
        mSelectionStart = -1;
        mSelectionEnd = -1;

        final String[] tokens = string.split(" +");
        for (String token : tokens) {
            if (token.startsWith("'") && token.endsWith("'")) {
                for (int i = 1; i < token.length() - 1; ++i) {
                    final char ch = token.charAt(1);
                    if (!isGraphicAscii(ch)) {
                        throw new IllegalArgumentException(
                                "Only printable characters can be in single quote. " +
                                "Use U+" + Integer.toHexString(ch).toUpperCase() + " instead");
                    }
                }
                sb.append(token.substring(1, token.length() - 1));
            } else if (token.startsWith("U+")) {
                final int codePoint = Integer.parseInt(token.substring(2), 16);
                if (codePoint < 0 || 0x10FFFF < codePoint) {
                    throw new IllegalArgumentException("Invalid code point is specified:" + token);
                }
                sb.append(Character.toChars(codePoint));
            } else if (token.equals(CURSOR)) {
                if (mSelectionStart != -1 || mSelectionEnd != -1) {
                    throw new IllegalArgumentException(
                            "Two or more cursor/selection positions are specified.");
                }
                mSelectionStart = mSelectionEnd = sb.length();
            } else if (token.equals(SELECTION_START)) {
                if (mSelectionStart != -1) {
                    throw new IllegalArgumentException(
                            "Two or more cursor/selection positions are specified.");
                }
                mSelectionStart = sb.length();
            } else if (token.equals(SELECTION_END)) {
                if (mSelectionEnd != -1) {
                    throw new IllegalArgumentException(
                            "Two or more cursor/selection positions are specified.");
                }
                mSelectionEnd = sb.length();
            } else if (token.equals(REPLACEMENT_SPAN_START)) {
                if (replacementSpanStart != -1) {
                    throw new IllegalArgumentException(
                            "Only one replacement span is supported");
                }
                replacementSpanStart = sb.length();
            } else if (token.equals(REPLACEMENT_SPAN_END)) {
                if (replacementSpanEnd != -1) {
                    throw new IllegalArgumentException(
                            "Only one replacement span is supported");
                }
                replacementSpanEnd = sb.length();
            } else {
                throw new IllegalArgumentException("Unknown or invalid token: " + token);
            }
        }

        if (mSelectionStart == -1 || mSelectionEnd == -1) {
              if (mSelectionEnd != -1) {
                  throw new IllegalArgumentException(
                          "Selection start position doesn't exist.");
              } else if (mSelectionStart != -1) {
                  throw new IllegalArgumentException(
                          "Selection end position doesn't exist.");
              } else {
                  throw new IllegalArgumentException(
                          "At least cursor position or selection range must be specified.");
              }
        } else if (mSelectionStart > mSelectionEnd) {
              throw new IllegalArgumentException(
                      "Selection start position appears after end position.");
        }

        final Spannable spannable = new SpannableString(sb.toString());

        if (replacementSpanStart != -1 || replacementSpanEnd != -1) {
            if (replacementSpanStart == -1) {
                throw new IllegalArgumentException(
                        "ReplacementSpan start position doesn't exist.");
            }
            if (replacementSpanEnd == -1) {
                throw new IllegalArgumentException(
                        "ReplacementSpan end position doesn't exist.");
            }
            if (replacementSpanStart > replacementSpanEnd) {
                throw new IllegalArgumentException(
                        "ReplacementSpan start position appears after end position.");
            }
            spannable.setSpan(new MockReplacementSpan(), replacementSpanStart, replacementSpanEnd,
                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
        }
        mText = Editable.Factory.getInstance().newEditable(spannable);
    }

    public void assertEquals(String string) {
        EditorState expected = new EditorState();
        expected.setByString(string);

        Assert.assertEquals(expected.mText.toString(), mText.toString());
        Assert.assertEquals(expected.mSelectionStart, mSelectionStart);
        Assert.assertEquals(expected.mSelectionEnd, mSelectionEnd);
    }
}
+62 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2016 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package android.text.method;

import android.app.Instrumentation;
import android.test.ActivityInstrumentationTestCase2;
import android.text.format.DateUtils;
import android.view.KeyEvent;
import android.widget.EditText;
import android.widget.TextViewActivity;

import com.android.frameworks.coretests.R;

public abstract class KeyListenerTestCase extends
        ActivityInstrumentationTestCase2<TextViewActivity> {

    protected TextViewActivity mActivity;
    protected Instrumentation mInstrumentation;
    protected EditText mTextView;

    public KeyListenerTestCase() {
        super(TextViewActivity.class);
    }

    @Override
    protected void setUp() throws Exception {
        super.setUp();

        mActivity = getActivity();
        mInstrumentation = getInstrumentation();
        mTextView = (EditText) mActivity.findViewById(R.id.textview);

        mActivity.runOnUiThread(new Runnable() {
            public void run() {
                // Ensure that the screen is on for this test.
                mTextView.setKeepScreenOn(true);
            }
        });

        assertTrue(mActivity.waitForWindowFocus(5 * DateUtils.SECOND_IN_MILLIS));
    }

    protected static KeyEvent getKey(int keycode, int metaState) {
        long currentTime = System.currentTimeMillis();
        return new KeyEvent(currentTime, currentTime, KeyEvent.ACTION_DOWN, keycode,
                0 /* repeat */, metaState);
    }
}
Loading