diff --git a/app/src/common/shared/org/mozilla/vrbrowser/ui/keyboards/KeyboardInterface.java b/app/src/common/shared/org/mozilla/vrbrowser/ui/keyboards/KeyboardInterface.java index adc4305b4..7baf09a45 100644 --- a/app/src/common/shared/org/mozilla/vrbrowser/ui/keyboards/KeyboardInterface.java +++ b/app/src/common/shared/org/mozilla/vrbrowser/ui/keyboards/KeyboardInterface.java @@ -32,8 +32,11 @@ public enum Action { float getAlphabeticKeyboardWidth(); default @Nullable CustomKeyboard getSymbolsKeyboard() { return null; } default @Nullable CandidatesResult getCandidates(String aComposingText) { return null; } + default @Nullable String overrideAddText(String aTextBeforeCursor, String aNextText) { return null; } + default @Nullable String overrideBackspace(String aTextBeforeCursor) { return null; } default boolean supportsAutoCompletion() { return false; } default boolean usesComposingText() { return false; } + default boolean usesTextOverride() { return false; } String getComposingText(String aComposing, String aCode); String getKeyboardTitle(); Locale getLocale(); diff --git a/app/src/common/shared/org/mozilla/vrbrowser/ui/keyboards/KoreanKeyboard.java b/app/src/common/shared/org/mozilla/vrbrowser/ui/keyboards/KoreanKeyboard.java new file mode 100644 index 000000000..c0c592089 --- /dev/null +++ b/app/src/common/shared/org/mozilla/vrbrowser/ui/keyboards/KoreanKeyboard.java @@ -0,0 +1,403 @@ +package org.mozilla.vrbrowser.ui.keyboards; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.XmlResourceParser; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.mozilla.vrbrowser.R; +import org.mozilla.vrbrowser.input.CustomKeyboard; +import org.mozilla.vrbrowser.utils.StringUtils; + +import java.util.ArrayList; +import java.util.Locale; + +public class KoreanKeyboard extends BaseKeyboard { + private CustomKoreanKeyboard mKeyboard; + /* + * The Korean Writing System: + * http://gernot-katzers-spice-pages.com/var/korean_hangul_unicode.html + * http://www.programminginkorean.com/programming/hangul-in-unicode/composing-syllables-in-unicode/ + */ + private static final String INITIALS = "ᄀㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ"; + private final static String VOWELS = "ᅡᅢᅣᅤᅥᅦᅧᅨᅩᅪᅫᅬᅭᅮᅯᅰᅱᅲᅳᅴᅵ"; + private final static String TAILS = "ᄀㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ"; + private final static String SINGLE_CONSONANTS = "ㅂㅈㄷᄀㅅ"; + private final static String DOUBLE_CONSONANTS = "ㅃㅉㄸㄲㅆ"; + private final static String SINGLE_VOWELS = "ᅢᅦ"; + private final static String DOUBLE_VOWELS = "ᅤᅨ"; + private final static String[] COMBINED_CONSONANTS = new String[] { + "ㄳᄀㅅ", "ㄵㄴㅈ", "ㄶㄴㅎ", "ㄺㄹᄀ", "ㄻㄹㅁ", "ㄼㄹㅂ", "ㄽㄹㅅ", "ㄾㄹㅌ", "ㄿㄹㅍ", "ㅀㄹㅎ", "ㅄㅂㅅ" + }; + private final static String[] COMBINED_VOWELS = new String[] { + "ᅦᅥᅵ", "ᅪᅩᅡ", "ᅫᅩᅢ", "ᅬᅩᅵ", "ᅯᅮᅥ", "ᅰᅮᅦ", "ᅱᅮᅵ", "ᅴᅳᅵ" + }; + private final static int HANGUL_UNICODE_START_VALUE = 44032; + private final static int HANGUL_UNICODE_LAST_VALUE = 55203; + private final static int AMOUNT_OF_TAILS = TAILS.length() + 1; // 28 + private final static int AMOUNT_OF_TAILS_THE_AMOUNT_OF_VOWELS = AMOUNT_OF_TAILS * VOWELS.length(); // 588 + + + class DecomposedHangul { + int initialIndex = -1; + int vowelIndex = -1; + int tailIndex = -1; + + boolean isInitial() { + return initialIndex >= 0 && vowelIndex < 0; + } + + boolean isVowel() { + return vowelIndex >= 0 && initialIndex < 0 && tailIndex < 0; + } + + boolean isTail() { + return tailIndex >= 0 && vowelIndex < 0; + } + + boolean isCompleteHangul() { + return initialIndex >= 0 && vowelIndex >= 0 && tailIndex >= 0; + } + + boolean isInitialAndVowel() { + return initialIndex >= 0 && vowelIndex >= 0 && tailIndex < 0; + } + + boolean combineVowel(String aSufix) { + if (!isVowel() && !isInitialAndVowel()) { + return false; + } + String prefix = VOWELS.substring(vowelIndex, vowelIndex + 1); + for (String values : COMBINED_VOWELS) { + if (values.indexOf(prefix) == 1 && values.indexOf(aSufix) == 2) { + vowelIndex = VOWELS.indexOf(values.substring(0, 1)); + return true; + } + } + + return false; + } + + boolean combineTail(String aSufix) { + if (!isCompleteHangul()) { + return false; + } + String prefix = TAILS.substring(tailIndex, tailIndex + 1); + for (String values : COMBINED_CONSONANTS) { + if (values.indexOf(prefix) == 1 && values.indexOf(aSufix) == 2) { + tailIndex = TAILS.indexOf(values.substring(0, 1)); + return true; + } + } + + return false; + } + + boolean removeCombinedVowel() { + if (!isVowel() && !isInitialAndVowel()) { + return false; + } + + String jamo = VOWELS.substring(vowelIndex, vowelIndex + 1); + for (String values : COMBINED_VOWELS) { + if (values.indexOf(jamo) == 0) { + vowelIndex = VOWELS.indexOf(values.substring(1, 2)); + return true; + } + } + return false; + } + + boolean removeCombinedTail() { + if (!isCompleteHangul()) { + return false; + } + + String jamo = TAILS.substring(tailIndex, tailIndex + 1); + for (String values : COMBINED_CONSONANTS) { + if (values.indexOf(jamo) == 0) { + tailIndex = TAILS.indexOf(values.substring(1, 2)); + return true; + } + } + return false; + } + + String getHangul() { + if (isVowel()) { + return VOWELS.substring(vowelIndex, vowelIndex + 1); + } else if (isInitial()) { + return INITIALS.substring(initialIndex, initialIndex + 1); + } else if (isTail()) { + return TAILS.substring(tailIndex, tailIndex + 1); + } else if (isInitialAndVowel() || isCompleteHangul()) { + // Compose Hangul syllable using Unicode math (initial + vowel + tail) + int charValue = HANGUL_UNICODE_START_VALUE + initialIndex * AMOUNT_OF_TAILS_THE_AMOUNT_OF_VOWELS; + if (vowelIndex >= 0) { + charValue += vowelIndex * AMOUNT_OF_TAILS; + if (tailIndex >= 0) { + charValue += tailIndex + 1; + } + } + + return new String(Character.toChars(charValue)); + } + + return ""; + } + } + + class CustomKoreanKeyboard extends CustomKeyboard { + private ArrayList mMutableConsonants; + private ArrayList mMutableVowels; + CustomKoreanKeyboard(Context context, int xmlLayoutResId) { + super(context, xmlLayoutResId); + } + + @Override + protected Key createKeyFromXml(Resources res, Row parent, int x, int y, XmlResourceParser parser) { + if (mMutableConsonants == null) { + mMutableConsonants = new ArrayList<>(); + mMutableVowels = new ArrayList<>(); + } + Key key = super.createKeyFromXml(res, parent, x, y, parser); + if (key != null && !StringUtils.isEmpty(key.label) ) { + // Consonants that are changed when the keyboard is shifted + if (SINGLE_CONSONANTS.contains(key.label)) { + mMutableConsonants.add(key); + } + // Vowels that are changed when the keyboard is shifted + if (SINGLE_VOWELS.contains(key.label)) { + mMutableVowels.add(key); + } + } + return key; + } + + + @Override + public boolean setShifted(boolean aShifted) { + boolean result = super.setShifted(aShifted); + // Update consonants depending on keyboard shift state. + for (Key key: mMutableConsonants) { + int index = SINGLE_CONSONANTS.indexOf(key.label.toString()); + if (index < 0) { + index = DOUBLE_CONSONANTS.indexOf(key.label.toString()); + } + key.label = aShifted ? getDoubleConsonant(index) :getSingleConsonant(index); + key.codes[0] = key.label.charAt(0); + } + // Update vowels depending on keyboard shift state. + for (Key key: mMutableVowels) { + int index = SINGLE_VOWELS.indexOf(key.label.toString()); + if (index < 0) { + index = DOUBLE_VOWELS.indexOf(key.label.toString()); + } + key.label = aShifted ? DOUBLE_VOWELS.substring(index, index + 1) : SINGLE_VOWELS.substring(index, index + 1); + key.codes[0] = key.label.charAt(0); + } + + return result; + } + } + + public KoreanKeyboard(Context aContext) { + super(aContext); + } + + @NonNull + @Override + public CustomKeyboard getAlphabeticKeyboard() { + if (mKeyboard == null) { + mKeyboard = new CustomKoreanKeyboard(mContext.getApplicationContext(), R.xml.keyboard_qwerty_korean); + } + return mKeyboard; + } + + @Nullable + @Override + public CandidatesResult getCandidates(String aText) { + return null; + } + + @Nullable + @Override + public String overrideAddText(String aTextBeforeCursor, String aNextText) { + if (StringUtils.isEmpty(aTextBeforeCursor) || StringUtils.isEmpty(aNextText)) { + return null; + } + + DecomposedHangul before = decompose(StringUtils.getLastCharacter(aTextBeforeCursor)); + DecomposedHangul after = decompose(StringUtils.getLastCharacter(aNextText)); + + String result = null; + + if (before.isInitial() && after.isInitial() && before.initialIndex == after.initialIndex && SINGLE_CONSONANTS.contains(getInitial(before.initialIndex))) { + // Generate double consonant from single consonants. + // Example: ㅅㅅ will convert to ㅆ + result = StringUtils.removeLastCharacter(aTextBeforeCursor); + result += getDoubleConsonant(SINGLE_CONSONANTS.indexOf(getInitial(before.initialIndex))); + } else if (after.isVowel() && before.combineVowel(getVowel(after.vowelIndex))) { + // Combine vowels. + // Example: ᅥᅵ will convert to ᅦ + result = StringUtils.removeLastCharacter(aTextBeforeCursor); + result += before.getHangul(); + } else if (after.isTail() && before.combineTail(getTail(after.tailIndex))) { + // Combine tails for complete Hanguls. + // Example: 식 will convert to 싟 (because the ending jamo tail ᄀㅅ will convert to ㄳ) + result = StringUtils.removeLastCharacter(aTextBeforeCursor); + result += before.getHangul(); + } else if (before.isInitial() && after.isVowel()) { + // Combine initial and vowel. + // Example: ㅂᅩ will produce 보 + result = StringUtils.removeLastCharacter(aTextBeforeCursor); + before.vowelIndex = after.vowelIndex; + before.tailIndex = -1; + result += before.getHangul(); + } else if (before.isInitialAndVowel() && after.isTail()) { + // Add tail to a Hangul with no tail. + // Example: 보ㅇ will produce 봉 + result = StringUtils.removeLastCharacter(aTextBeforeCursor); + before.tailIndex = after.tailIndex; + result += before.getHangul(); + } else if (before.isCompleteHangul() && after.isVowel() && INITIALS.contains(getTail(before.tailIndex))) { + // Split Hangul when vowel is added after a complete Hangul. + // Example: ㅂㅏㅂ will produce 밥. Another ㅏ will produce 바바 instead of 밥ㅏ + result = StringUtils.removeLastCharacter(aTextBeforeCursor); + after.initialIndex = INITIALS.indexOf(getTail(before.tailIndex)); + before.tailIndex = -1; + result += before.getHangul(); + result += after.getHangul(); + } + + return result; + } + + @Nullable + @Override + public String overrideBackspace(String aTextBeforeCursor) { + if (StringUtils.isEmpty(aTextBeforeCursor)) { + return null; + } + + String result = null; + DecomposedHangul last = decompose(StringUtils.getLastCharacter(aTextBeforeCursor)); + if (last.removeCombinedTail()) { + // Remove the combined tail from a Hangul. + // Example: 싟 will produce 식 (the ㅅ tail is removed from the combined ㄳ tail) + result = StringUtils.removeLastCharacter(aTextBeforeCursor); + result += last.getHangul(); + } else if (last.removeCombinedVowel()) { + // Remove the combined vowel from a Hangul or a jamo. + // Example: ᅦ will produce ᅥ (The ᅵ jamo was removed) + result = StringUtils.removeLastCharacter(aTextBeforeCursor); + result += last.getHangul(); + } else if (last.isCompleteHangul()) { + // Remove the tail from a Hangul. + // Example: 봉 will produce 보 (the ㅇ tail is removed) + last.tailIndex = -1; + result = StringUtils.removeLastCharacter(aTextBeforeCursor); + result += last.getHangul(); + } else if (last.isInitialAndVowel()) { + result = StringUtils.removeLastCharacter(aTextBeforeCursor); + DecomposedHangul before = decompose(StringUtils.getLastCharacter(result)); + if (before.isInitialAndVowel() && TAILS.contains(getInitial(last.initialIndex))) { + // Remove the vowel from a Hangul and use the remaining initial as tail for the previous Hangul. + // Example: 바바 will produce 밥 (The ㅏ vowel is removed and ㅂㅏㅂ is combined into a single Hangul) + result = StringUtils.removeLastCharacter(result); + before.tailIndex = TAILS.indexOf(getInitial(last.initialIndex)); + result += before.getHangul(); + } else { + // Remove the vowel from a Hangul. + // Example: 보 will produce ㅂ (the ᅩ vowel is removed) + result += getInitial(last.initialIndex); + } + } else if (last.isInitial() && DOUBLE_CONSONANTS.contains(getInitial(last.initialIndex))) { + // Generate single consonant from double consonants. + // Example: ㅆ will convert to ㅅ + result = StringUtils.removeLastCharacter(aTextBeforeCursor); + result += getSingleConsonant(DOUBLE_CONSONANTS.indexOf(getInitial(last.initialIndex))); + } + + return result; + } + + private DecomposedHangul decompose(String aCharacter) { + DecomposedHangul result = new DecomposedHangul(); + if (StringUtils.isEmpty(aCharacter)) { + return result; + } + + // Check if it's a Hangul character + final int charValue = aCharacter.codePointAt(0); // Unicode value + if (charValue >= HANGUL_UNICODE_START_VALUE && charValue <= HANGUL_UNICODE_LAST_VALUE) { + result.initialIndex = (charValue - HANGUL_UNICODE_START_VALUE) / AMOUNT_OF_TAILS_THE_AMOUNT_OF_VOWELS; + result.tailIndex = ((charValue - HANGUL_UNICODE_START_VALUE) % AMOUNT_OF_TAILS) - 1; + result.vowelIndex = ((charValue - HANGUL_UNICODE_START_VALUE - result.tailIndex) % AMOUNT_OF_TAILS_THE_AMOUNT_OF_VOWELS) / AMOUNT_OF_TAILS; + return result; + } + + // Check if it is a Jamo character + result.initialIndex = INITIALS.indexOf(aCharacter); + result.vowelIndex = VOWELS.indexOf(aCharacter); + result.tailIndex = TAILS.indexOf(aCharacter); + + return result; + } + + private String getVowel(int aIndex) { + if (aIndex < 0 || aIndex >= VOWELS.length()) { + return ""; + } + + return VOWELS.substring(aIndex, aIndex + 1); + } + + + private String getTail(int aIndex) { + if (aIndex < 0 || aIndex >= TAILS.length()) { + return ""; + } + + return TAILS.substring(aIndex, aIndex + 1); + } + + private String getInitial(int aIndex) { + if (aIndex < 0 || aIndex >= INITIALS.length()) { + return ""; + } + + return INITIALS.substring(aIndex, aIndex + 1); + } + + private String getSingleConsonant(int aIndex) { + if (aIndex < 0 || aIndex >= SINGLE_CONSONANTS.length()) { + return ""; + } + + return SINGLE_CONSONANTS.substring(aIndex, aIndex + 1); + } + + private String getDoubleConsonant(int aIndex) { + if (aIndex < 0 || aIndex >= DOUBLE_CONSONANTS.length()) { + return ""; + } + + return DOUBLE_CONSONANTS.substring(aIndex, aIndex + 1); + } + + @Override + public boolean usesTextOverride() { return true; } + + @Override + public String getKeyboardTitle() { + return StringUtils.getStringByLocale(mContext, R.string.settings_language_korean, getLocale()); + } + + @Override + public Locale getLocale() { + return Locale.KOREAN; + } +} diff --git a/app/src/common/shared/org/mozilla/vrbrowser/ui/widgets/KeyboardWidget.java b/app/src/common/shared/org/mozilla/vrbrowser/ui/widgets/KeyboardWidget.java index fcd982b09..6ef3a015f 100644 --- a/app/src/common/shared/org/mozilla/vrbrowser/ui/widgets/KeyboardWidget.java +++ b/app/src/common/shared/org/mozilla/vrbrowser/ui/widgets/KeyboardWidget.java @@ -40,6 +40,7 @@ import org.mozilla.vrbrowser.ui.keyboards.ChineseZhuyinKeyboard; import org.mozilla.vrbrowser.ui.keyboards.KeyboardInterface; import org.mozilla.vrbrowser.ui.keyboards.RussianKeyboard; +import org.mozilla.vrbrowser.ui.keyboards.KoreanKeyboard; import org.mozilla.vrbrowser.ui.keyboards.SpanishKeyboard; import org.mozilla.vrbrowser.ui.views.AutoCompletionView; import org.mozilla.vrbrowser.ui.views.CustomKeyboardView; @@ -137,6 +138,7 @@ private void initialize(Context aContext) { mKeyboards.add(new RussianKeyboard(aContext)); mKeyboards.add(new ChinesePinyinKeyboard(aContext)); mKeyboards.add(new ChineseZhuyinKeyboard(aContext)); + mKeyboards.add(new KoreanKeyboard(aContext)); mDefaultKeyboardSymbols = new CustomKeyboard(aContext.getApplicationContext(), R.xml.keyboard_symbols); mKeyboardNumeric = new CustomKeyboard(aContext.getApplicationContext(), R.xml.keyboard_numeric); @@ -533,23 +535,33 @@ private void handleBackspace(final boolean isLongPress) { updateCandidates(); return; } - postInputCommand(() -> { - CharSequence selectedText = mInputConnection.getSelectedText(0); - if (selectedText == null || selectedText.length() == 0) { - if (isLongPress) { - CharSequence currentText = connection.getExtractedText(new ExtractedTextRequest(), 0).text; - CharSequence beforeCursorText = connection.getTextBeforeCursor(currentText.length(), 0); - CharSequence afterCursorText = connection.getTextAfterCursor(currentText.length(), 0); - connection.deleteSurroundingText(beforeCursorText.length(), afterCursorText.length()); - - } else { - // No selected text to delete. Remove the character before the cursor. - connection.deleteSurroundingText(1, 0); - } - } else { + postInputCommand(() -> { + CharSequence selectedText = connection.getSelectedText(0); + if (selectedText != null && selectedText.length() > 0) { // Delete the selected text connection.commitText("", 1); + return; + } + + if (isLongPress) { + CharSequence currentText = connection.getExtractedText(new ExtractedTextRequest(), 0).text; + CharSequence beforeCursorText = connection.getTextBeforeCursor(currentText.length(), 0); + CharSequence afterCursorText = connection.getTextAfterCursor(currentText.length(), 0); + connection.deleteSurroundingText(beforeCursorText.length(), afterCursorText.length()); + } else { + if (mCurrentKeyboard.usesTextOverride()) { + String beforeText = getTextBeforeCursor(connection); + String newBeforeText = mCurrentKeyboard.overrideBackspace(beforeText); + if (newBeforeText != null) { + // Replace whole before text + connection.deleteSurroundingText(beforeText.length(), 0); + connection.commitText(newBeforeText, 1); + return; + } + } + // Remove the character before the cursor. + connection.deleteSurroundingText(1, 0); } }); } @@ -658,6 +670,19 @@ private void handleText(final String aText) { mComposingText = ""; } mComposingText += aText; + } else if (mCurrentKeyboard.usesTextOverride()) { + String beforeText = getTextBeforeCursor(mInputConnection); + final String newBeforeText = mCurrentKeyboard.overrideAddText(beforeText, aText); + final InputConnection connection = mInputConnection; + postInputCommand(() -> { + if (newBeforeText != null) { + connection.deleteSurroundingText(beforeText.length(), 0); + connection.commitText(newBeforeText, 1); + } else { + connection.commitText(aText, 1); + } + }); + } else { final InputConnection connection = mInputConnection; postInputCommand(() -> connection.commitText(aText, 1)); @@ -682,6 +707,15 @@ private void handleVoiceInput() { mWidgetManager.updateWidget(this); } + private String getTextBeforeCursor(InputConnection aConnection) { + if (aConnection == null) { + return ""; + } + + String fullText = aConnection.getExtractedText(new ExtractedTextRequest(),0).text.toString(); + return aConnection.getTextBeforeCursor(fullText.length(),0).toString(); + } + private void postInputCommand(Runnable aRunnable) { if (mInputConnection == null) { Log.e(LOGTAG, "InputConnection command not submitted, mInputConnection was null"); diff --git a/app/src/common/shared/org/mozilla/vrbrowser/utils/StringUtils.java b/app/src/common/shared/org/mozilla/vrbrowser/utils/StringUtils.java index f94225bbe..65ebf6558 100644 --- a/app/src/common/shared/org/mozilla/vrbrowser/utils/StringUtils.java +++ b/app/src/common/shared/org/mozilla/vrbrowser/utils/StringUtils.java @@ -25,4 +25,24 @@ public static String removeSpaces(@NonNull String aText) { public static boolean isEmpty(String aString) { return aString == null || aString.length() == 0; } + + public static boolean isEmpty(CharSequence aSequence) { + return aSequence == null || aSequence.length() == 0; + } + + + public static String getLastCharacter(String aText) { + if (!isEmpty(aText)) { + return aText.substring(aText.length() - 1); + } + + return ""; + } + + public static String removeLastCharacter(String aText) { + if (!isEmpty(aText)) { + return aText.substring(0, aText.length() - 1); + } + return ""; + } } diff --git a/app/src/main/res/xml/keyboard_qwerty_korean.xml b/app/src/main/res/xml/keyboard_qwerty_korean.xml new file mode 100644 index 000000000..d0fe52727 --- /dev/null +++ b/app/src/main/res/xml/keyboard_qwerty_korean.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file