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

Commit 3c6e3081 authored by Seigo Nonaka's avatar Seigo Nonaka Committed by Android (Google) Code Review
Browse files

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

parents 8a5c6406 ff3bfd5a
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