From 54abf446280f63b734fb9729dd33c18c59c2fe57 Mon Sep 17 00:00:00 2001 From: Helium314 Date: Sat, 30 Dec 2023 23:18:10 +0100 Subject: [PATCH] Make layouts customizable (#361) now layouts can be added from files, or default layouts can be copied and edited --- README.md | 1 + .../keyboard/KeyboardLayoutSet.java | 9 + .../keyboard/MoreKeysKeyboard.java | 7 +- .../keyboard/internal/KeyPreviewView.java | 5 + .../keyboard/internal/KeySpecParser.java | 18 +- .../keyboard/internal/KeyboardBuilder.kt | 65 +------ .../keyboard_parser/KeyboardParser.kt | 48 +++-- .../keyboard_parser/SimpleKeyboardParser.kt | 2 +- .../latin/RichInputMethodSubtype.java | 7 +- .../inputmethod/latin/common/StringUtils.kt | 1 + .../settings/AdvancedSettingsFragment.kt | 1 + .../latin/settings/LanguageSettingsDialog.kt | 92 ++++++++-- .../settings/LanguageSettingsFragment.kt | 15 ++ .../latin/settings/SubtypeSettings.kt | 39 +++- .../latin/utils/AdditionalSubtypeUtils.java | 20 +-- .../latin/utils/CustomLayoutUtils.kt | 169 ++++++++++++++++++ .../inputmethod/latin/utils/ScriptUtils.java | 1 - .../latin/utils/SubtypeLocaleUtils.java | 2 + app/src/main/res/values/strings.xml | 16 +- .../openboard/inputmethod/latin/ParserTest.kt | 101 +++++++++++ layouts.md | 32 +++- 21 files changed, 522 insertions(+), 129 deletions(-) create mode 100644 app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/CustomLayoutUtils.kt create mode 100644 app/src/test/java/org/dslul/openboard/inputmethod/latin/ParserTest.kt diff --git a/README.md b/README.md index 846e40ec..31bd2b8b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Might end up on F-Droid... * Number pad * Show all available extra characters on long pressing a key * Backup your learned word / history data +* Add custom keyboard layouts * Adjustable bottom padding ## Hidden functionality diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/KeyboardLayoutSet.java b/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/KeyboardLayoutSet.java index 1ce6dd67..72b11faa 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/KeyboardLayoutSet.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/KeyboardLayoutSet.java @@ -438,4 +438,13 @@ public final class KeyboardLayoutSet { } } } + + // used for testing keyboard layout files without actually creating a keyboard + public static KeyboardId getFakeKeyboardId(final int elementId) { + final Params params = new Params(); + params.mEditorInfo = new EditorInfo(); + params.mSubtype = RichInputMethodSubtype.getEmojiSubtype(); + params.mSubtype.getKeyboardLayoutSetName(); + return new KeyboardId(elementId, params); + } } diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/MoreKeysKeyboard.java b/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/MoreKeysKeyboard.java index 261e87c5..7ab57d0c 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/MoreKeysKeyboard.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/MoreKeysKeyboard.java @@ -300,7 +300,12 @@ public final class MoreKeysKeyboard extends Keyboard { dividerWidth = 0; } final MoreKeySpec[] moreKeys = key.getMoreKeys(); - mParams.setParameters(moreKeys.length, key.getMoreKeysColumnNumber(), keyWidth, + final int defaultColumns = key.getMoreKeysColumnNumber(); + final int spaceForKeys = keyboard.mId.mWidth / keyWidth; + final int finalNumColumns = spaceForKeys >= Math.min(moreKeys.length, defaultColumns) + ? defaultColumns + : (spaceForKeys > 0 ? spaceForKeys : defaultColumns); // in last case setParameters will throw an exception + mParams.setParameters(moreKeys.length, finalNumColumns, keyWidth, rowHeight, key.getX() + key.getWidth() / 2, keyboard.mId.mWidth, key.isMoreKeysFixedColumn(), key.isMoreKeysFixedOrder(), dividerWidth); } diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/KeyPreviewView.java b/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/KeyPreviewView.java index e8e70c44..bd0e9f5e 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/KeyPreviewView.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/KeyPreviewView.java @@ -19,6 +19,7 @@ import androidx.appcompat.widget.AppCompatTextView; import org.dslul.openboard.inputmethod.keyboard.Key; import org.dslul.openboard.inputmethod.latin.R; +import org.dslul.openboard.inputmethod.latin.common.StringUtilsKt; import java.util.HashSet; @@ -66,6 +67,10 @@ public class KeyPreviewView extends AppCompatTextView { if (sNoScaleXTextSet.contains(text)) { return; } + if (StringUtilsKt.isEmoji(text)) { + sNoScaleXTextSet.add(text); + return; + } // TODO: Override {@link #setBackground(Drawable)} that is supported from API 16 and // calculate maximum text width. final Drawable background = getBackground(); diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/KeySpecParser.java b/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/KeySpecParser.java index 2dcd77eb..63de2d11 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/KeySpecParser.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/KeySpecParser.java @@ -8,6 +8,7 @@ package org.dslul.openboard.inputmethod.keyboard.internal; import org.dslul.openboard.inputmethod.latin.common.Constants; import org.dslul.openboard.inputmethod.latin.common.StringUtils; +import org.dslul.openboard.inputmethod.latin.define.DebugFlags; import static org.dslul.openboard.inputmethod.latin.common.Constants.CODE_OUTPUT_TEXT; import static org.dslul.openboard.inputmethod.latin.common.Constants.CODE_UNSPECIFIED; @@ -88,7 +89,9 @@ public final class KeySpecParser { // Treat a sole vertical bar as a special case of key label. return -1; } - throw new KeySpecParserError("Empty label"); + if (DebugFlags.DEBUG_ENABLED) + throw new KeySpecParserError("Empty label"); + else return -1; } return labelEnd; } @@ -118,7 +121,8 @@ public final class KeySpecParser { if (indexOfLabelEnd(getAfterLabelEnd(keySpec, labelEnd)) < 0) { return; } - throw new KeySpecParserError("Multiple " + VERTICAL_BAR + ": " + keySpec); + if (DebugFlags.DEBUG_ENABLED) + throw new KeySpecParserError("Multiple " + VERTICAL_BAR + ": " + keySpec); } @Nullable @@ -132,7 +136,7 @@ public final class KeySpecParser { } final int labelEnd = indexOfLabelEnd(keySpec); final String label = parseEscape(getBeforeLabelEnd(keySpec, labelEnd)); - if (label.isEmpty()) { + if (label.isEmpty() && DebugFlags.DEBUG_ENABLED) { throw new KeySpecParserError("Empty label: " + keySpec); } return label; @@ -164,13 +168,13 @@ public final class KeySpecParser { // See {@link #getCode(Resources, String)}. return null; } - if (outputText.isEmpty()) { + if (outputText.isEmpty() && DebugFlags.DEBUG_ENABLED) { throw new KeySpecParserError("Empty outputText: " + keySpec); } return outputText; } final String label = getLabel(keySpec); - if (label == null) { + if (label == null && DebugFlags.DEBUG_ENABLED) { throw new KeySpecParserError("Empty label: " + keySpec); } // Code is automatically generated for one letter label. See {@link getCode()}. @@ -198,7 +202,9 @@ public final class KeySpecParser { } final String label = getLabel(keySpec); if (label == null) { - throw new KeySpecParserError("Empty label: " + keySpec); + if (DebugFlags.DEBUG_ENABLED) + throw new KeySpecParserError("Empty label: " + keySpec); + else return CODE_OUTPUT_TEXT; } // Code is automatically generated for one letter label. return (StringUtils.codePointCount(label) == 1) ? label.codePointAt(0) : CODE_OUTPUT_TEXT; diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/KeyboardBuilder.kt b/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/KeyboardBuilder.kt index a09e651f..71d15a9d 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/KeyboardBuilder.kt +++ b/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/KeyboardBuilder.kt @@ -47,66 +47,6 @@ open class KeyboardBuilder(protected val mContext: Context, mParams.mAllowRedundantMoreKeys = enabled } - // todo: further plan - // after the old parser is removed - // maybe the language -> layout thing could be moved to assets? and maybe even here the extra keys could be defined... - // should be either both in method.xml, or both in assets (actually method might be more suitable) - // go through a lot of todos in parsers, key, keyboardlayoutset, ... as a lot of things should only change after old parser is removed - // also remove the keyboard_layout_set files? - // allow users to define their own layouts (maybe do everything else first?) - // need to solve the scaling issue with number row and 5 row keyboards - // write up how things work for users, also regarding language more keys - // readme, maybe also some "help" button in a dialog - // some sort of proper UI, or simply text input? - // better text import for the start because of much work - // ui follows later (consider that users need to be able to start from existing layouts!) - // some warning if more than 2 or 3 characters on a single label - // currently can't resize keys, but could set autoXScale (does only decrease size, never increase) - // careful about moreKeys: if moreKeys don't fit on screen, parser throws an exception! - // need to somehow test for this - // is that autoColumnOrder thing a workaround for that? - // still would crash for a single huge label - // potential keyspec parsing issues: - // MoreKeySpec constructor does things like KeySpecParser.getLabel and others - // these work with special characters like | and \ doing things depending on their position - // if used wrongly, things can crash - // -> maybe disable this style of parsing when creating MoreKeySpec of a user-provided layout - // or also for the simple layouts, because there is no need to have it in layouts - // does the same issue apply to normal key labels? - // popup and (single key) long press preview rescale the label on x only, which may deform emojis - // does glide typing work with multiple letters on one key? if not, users should be notified - // maybe allow users to define their own symbol and shift-symbol layouts - // allow users to import layouts, which essentially just fills the text from a file - - // labelFlags should be set correctly - // alignHintLabelToBottom: on lxx and rounded themes, but did not find what it actually does... - // alignIconToBottom: space_key_for_number_layout - // alignLabelOffCenter: number keys in phone layout - // fontNormal: turkish (rows 1 and 2 only), .com, emojis, numModeKeyStyle, a bunch of non-latin languages - // -> switches to normal typeface, only relevant for holo which has bold - // fontMonoSpace: unused - // fontDefault: keyExclamationQuestion, a bunch of "normal" keys in fontNormal layouts like thai - // -> switches to default defined typeface, useful e.g. if row has fontNormal - // followKeyLargeLetterRatio: number keys in number/phone/numpad layouts - // followKeyLetterRatio: mode keys in number layouts, some keys in some non-latin layouts - // followKeyLabelRatio: enter key, some keys in phone layout (same as followKeyLetterRatio + followKeyLargeLetterRatio) - // followKeyHintLabelRatio: unused directly (but includes some others) - // hasPopupHint: basically the long-pressable functional keys - // hasShiftedLetterHint: period key and some keys on pcqwerty - // hasHintLabel: number keys in number layouts - // autoXScale: com key, action keys, some on phone layout, some non-latin languages - // autoScale: only one single letter in khmer layout (includes autoXScale) - // preserveCase: action key + more keys, com key, shift keys - // shiftedLetterActivated: period and some keys on pcqwerty, tablet only (wtf, when enabled can't open moreKeys -> remove? or what would be the use?) - // fromCustomActionLabel: action key with customLabelActionKeyStyle -> check parser where to get this info - // followFunctionalTextColor: number mode keys, action key - // keepBackgroundAspectRatio: lxx and rounded action more keys, lxx no-border action and emoji, moreKeys keyboard view - // disableKeyHintLabel: keys in pcqwerty row 1 and number row - // disableAdditionalMoreKeys: only keys in pcqwerty row 1 so there is no number row -> not necessary with the new layouts, just remove it completely - // maybe remove some of the flags? or keep supporting them? - // for pcqwerty: hasShiftedLetterHint -> hasShiftedLetterHint|shiftedLetterActivated when shift is enabled, need to consider if the flag is used - // actually period key also has shifted letter hint - fun load(xmlId: Int, id: KeyboardId): KeyboardBuilder { mParams.mId = id if (id.isEmojiKeyboard) { @@ -120,7 +60,10 @@ open class KeyboardBuilder(protected val mContext: Context, mParams.mMoreKeyTypes.addAll(sv.mMoreKeyTypes) // add label source only if moreKey type enabled sv.mMoreKeyLabelSources.forEach { if (it in sv.mMoreKeyTypes) mParams.mMoreKeyLabelSources.add(it) } - keysInRows = KeyboardParser.parseFromAssets(mParams, mContext) + keysInRows = if (mParams.mId.isAlphabetKeyboard && mParams.mId.mSubtype.isCustom) + KeyboardParser.parseCustom(mParams, mContext) + else + KeyboardParser.parseFromAssets(mParams, mContext) determineAbsoluteValues() } catch (e: Exception) { Log.e(TAG, "error parsing layout $id ${id.mElementId}", e) diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/keyboard_parser/KeyboardParser.kt b/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/keyboard_parser/KeyboardParser.kt index 4e050cd3..c74de3b7 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/keyboard_parser/KeyboardParser.kt +++ b/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/keyboard_parser/KeyboardParser.kt @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: GPL-3.0-only package org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser import android.content.Context @@ -6,7 +7,6 @@ import android.content.res.Resources import android.os.Build import org.dslul.openboard.inputmethod.latin.utils.Log import android.view.inputmethod.EditorInfo -import android.widget.Toast import androidx.annotation.StringRes import org.dslul.openboard.inputmethod.keyboard.Key import org.dslul.openboard.inputmethod.keyboard.Key.KeyParams @@ -19,16 +19,19 @@ import org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser.floris. import org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser.floris.SimplePopups import org.dslul.openboard.inputmethod.latin.R import org.dslul.openboard.inputmethod.latin.common.Constants +import org.dslul.openboard.inputmethod.latin.common.isEmoji import org.dslul.openboard.inputmethod.latin.common.splitOnWhitespace import org.dslul.openboard.inputmethod.latin.define.DebugFlags import org.dslul.openboard.inputmethod.latin.settings.Settings import org.dslul.openboard.inputmethod.latin.spellcheck.AndroidSpellCheckerService +import org.dslul.openboard.inputmethod.latin.utils.CUSTOM_LAYOUT_PREFIX import org.dslul.openboard.inputmethod.latin.utils.InputTypeUtils import org.dslul.openboard.inputmethod.latin.utils.MORE_KEYS_LAYOUT import org.dslul.openboard.inputmethod.latin.utils.MORE_KEYS_NUMBER import org.dslul.openboard.inputmethod.latin.utils.RunInLocale import org.dslul.openboard.inputmethod.latin.utils.ScriptUtils import org.dslul.openboard.inputmethod.latin.utils.sumOf +import java.io.File import java.util.Locale /** @@ -111,10 +114,8 @@ abstract class KeyboardParser(private val params: KeyboardParams, private val co // setting the correct moreKeys is handled in PopupSet // not for korean/lao/thai layouts, todo: should be decided in the layout / layoutInfos, not in the parser baseKeys.first().take(10).forEachIndexed { index, keyData -> keyData.popup.numberIndex = index } - if (DebugFlags.DEBUG_ENABLED && baseKeys.first().size < 10) { - val message = "first row only has ${baseKeys.first().size} keys: ${baseKeys.first().map { it.label }}" - Log.w(TAG, message) - Toast.makeText(context, message, Toast.LENGTH_LONG).show() + if (baseKeys.first().size < 10) { + Log.w(TAG, "first row only has ${baseKeys.first().size} keys: ${baseKeys.first().map { it.label }}") } } @@ -188,9 +189,10 @@ abstract class KeyboardParser(private val params: KeyboardParams, private val co } for (key in row) { - // todo: maybe autoScale / autoXScale if label has more than 2 characters (exception for emojis?) - // but that could also be determined in toKeyParams - val keyParams = key.compute(params).toKeyParams(params, keyWidth, defaultLabelFlags) + val extraFlags = if (key.label.length > 2 && key.label.codePointCount(0, key.label.length) > 2 && !isEmoji(key.label)) + Key.LABEL_FLAGS_AUTO_X_SCALE + else 0 + val keyParams = key.compute(params).toKeyParams(params, keyWidth, defaultLabelFlags or extraFlags) paramsRow.add(keyParams) if (DebugFlags.DEBUG_ENABLED) Log.d(TAG, "adding key ${keyParams.mLabel}, ${keyParams.mCode}") @@ -239,6 +241,8 @@ abstract class KeyboardParser(private val params: KeyboardParams, private val co val rowAboveLastNormalRowKeyWidth = rowAboveLastNormalRow.first { it.mBackgroundType == Key.BACKGROUND_TYPE_NORMAL }.mRelativeWidth if (lastNormalRowKeyWidth <= rowAboveLastNormalRowKeyWidth + 0.0001f) return // no need + if (lastNormalRowKeyWidth / rowAboveLastNormalRowKeyWidth > 1.1f) + return // don't resize on large size difference if (lastNormalRow.any { it.mBackgroundType == Key.BACKGROUND_TYPE_NORMAL && it.mRelativeWidth != lastNormalRowKeyWidth }) return // normal keys have different width, don't deal with this val numberOfNormalKeys = lastNormalRow.count { it.mBackgroundType == Key.BACKGROUND_TYPE_NORMAL } @@ -667,10 +671,7 @@ abstract class KeyboardParser(private val params: KeyboardParams, private val co return this val id = context.resources.getIdentifier("label_$this", "string", context.packageName) if (id == 0) { - val message = "no resource for label $this in ${params.mId}" - Log.w(TAG, message) - if (DebugFlags.DEBUG_ENABLED) - Toast.makeText(context, message, Toast.LENGTH_LONG).show() + Log.w(TAG, "no resource for label $this in ${params.mId}") return this } return getInLocale(id) @@ -782,6 +783,15 @@ abstract class KeyboardParser(private val params: KeyboardParams, private val co companion object { private val TAG = KeyboardParser::class.simpleName + fun parseCustom(params: KeyboardParams, context: Context): ArrayList> { + val layoutName = params.mId.mSubtype.keyboardLayoutSetName + val f = File(context.filesDir, "layouts${File.separator}$layoutName") + return if (layoutName.endsWith(".json")) + JsonKeyboardParser(params, context).parseLayoutString(f.readText()) + else + SimpleKeyboardParser(params, context).parseLayoutString(f.readText()) + } + fun parseFromAssets(params: KeyboardParams, context: Context): ArrayList> { val id = params.mId val layoutName = params.mId.mSubtype.keyboardLayoutSetName.substringBefore("+") @@ -812,21 +822,20 @@ abstract class KeyboardParser(private val params: KeyboardParams, private val co // touchPositionCorrectionData needs to be loaded, currently always holo is applied in readAttributes private fun layoutInfos(params: KeyboardParams): LayoutInfos { val name = params.mId.mSubtype.keyboardLayoutSetName - val labelFlags = if (!params.mId.isAlphabetKeyboard) 0 else when (name) { - "armenian_phonetic", "arabic", "arabic_pc", "bengali", "bengali_akkhor", "bengali_unijoy", - "farsi", "hindi", "hindi_compact", "lao", "marathi", "nepali_romanized", "nepali_traditional", - "thai", "urdu" -> Key.LABEL_FLAGS_FONT_NORMAL - "kannada", "khmer", "malayalam", "sinhala", "tamil", "telugu" -> Key.LABEL_FLAGS_FONT_NORMAL or Key.LABEL_FLAGS_AUTO_X_SCALE + val language = params.mId.locale.language + val labelFlags = if (!params.mId.isAlphabetKeyboard) 0 else when (language) { + "hy", "ar", "be", "fa", "hi", "lo", "mr", "ne", "th", "ur" -> Key.LABEL_FLAGS_FONT_NORMAL + "kn", "km", "ml", "si", "ta", "te" -> Key.LABEL_FLAGS_FONT_NORMAL or Key.LABEL_FLAGS_AUTO_X_SCALE else -> 0 } // only for alphabet, but some exceptions for shift layouts val enableProximityCharsCorrection = params.mId.isAlphabetKeyboard && when (name) { // todo: test effect on correction (just add qwerty to the list for testing) - "akkhor", "georgian", "hindi", "lao", "nepali_romanized", "nepali_traditional", "sinhala", "thai" -> + "bengali_akkhor", "georgian", "hindi", "lao", "nepali_romanized", "nepali_traditional", "sinhala", "thai" -> params.mId.mElementId == KeyboardId.ELEMENT_ALPHABET else -> true } - val allowRedundantMoreKeys = name != "nordic" && name != "serbian_qwertz" && params.mId.mElementId != KeyboardId.ELEMENT_SYMBOLS + val allowRedundantMoreKeys = params.mId.mElementId != KeyboardId.ELEMENT_SYMBOLS // todo: always set to false? // essentially this is default for 4 row and non-alphabet layouts, maybe this could be determined automatically instead of using a list // todo: check the difference between default (i.e. none) and holo (test behavior on keyboard) // todo: null for MoreKeysKeyboard only @@ -834,6 +843,7 @@ abstract class KeyboardParser(private val params: KeyboardParams, private val co R.array.touch_position_correction_data_default else R.array.touch_position_correction_data_holo val hasZwnjKey = params.mId.locale.language in listOf("fa", "ne", "kn", "te") // determine from language, user might have custom layout + // custom non-json layout for non-uppercase language should not have shift key val hasShiftKey = !params.mId.isAlphabetKeyboard || name !in listOf("hindi_compact", "bengali", "arabic", "arabic_pc", "hebrew", "kannada", "malayalam", "marathi", "farsi", "tamil", "telugu") return LayoutInfos(labelFlags, enableProximityCharsCorrection, allowRedundantMoreKeys, touchPositionCorrectionData, hasZwnjKey, hasShiftKey) } diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/keyboard_parser/SimpleKeyboardParser.kt b/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/keyboard_parser/SimpleKeyboardParser.kt index 17a2cbb3..c3d2d21d 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/keyboard_parser/SimpleKeyboardParser.kt +++ b/app/src/main/java/org/dslul/openboard/inputmethod/keyboard/internal/keyboard_parser/SimpleKeyboardParser.kt @@ -21,7 +21,7 @@ class SimpleKeyboardParser(private val params: KeyboardParams, private val conte context.assets.open("layouts/$layoutName.txt").reader().readText() override fun parseCoreLayout(layoutContent: String): MutableList> { - val rowStrings = layoutContent.replace("\r\n", "\n").split("\n\n") + val rowStrings = layoutContent.replace("\r\n", "\n").split("\\n\\s*\\n".toRegex()) return rowStrings.mapIndexedNotNullTo(mutableListOf()) { i, row -> if (row.isBlank()) return@mapIndexedNotNullTo null if (addExtraKeys) diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/RichInputMethodSubtype.java b/app/src/main/java/org/dslul/openboard/inputmethod/latin/RichInputMethodSubtype.java index 57ffa2e5..50cf6aa4 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/latin/RichInputMethodSubtype.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/RichInputMethodSubtype.java @@ -7,12 +7,13 @@ package org.dslul.openboard.inputmethod.latin; import android.os.Build; -import org.dslul.openboard.inputmethod.latin.utils.Log; import android.view.inputmethod.InputMethodSubtype; import org.dslul.openboard.inputmethod.compat.InputMethodSubtypeCompatUtils; import org.dslul.openboard.inputmethod.latin.common.Constants; import org.dslul.openboard.inputmethod.latin.common.LocaleUtils; +import org.dslul.openboard.inputmethod.latin.utils.CustomLayoutUtilsKt; +import org.dslul.openboard.inputmethod.latin.utils.Log; import org.dslul.openboard.inputmethod.latin.utils.SubtypeLocaleUtils; import java.util.HashMap; @@ -72,6 +73,10 @@ public class RichInputMethodSubtype { return SubtypeLocaleUtils.NO_LANGUAGE.equals(mSubtype.getLocale()); } + public boolean isCustom() { + return getKeyboardLayoutSetName().startsWith(CustomLayoutUtilsKt.CUSTOM_LAYOUT_PREFIX); + } + public String getNameForLogging() { return toString(); } diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/common/StringUtils.kt b/app/src/main/java/org/dslul/openboard/inputmethod/latin/common/StringUtils.kt index f63f96b5..cdd7c757 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/latin/common/StringUtils.kt +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/common/StringUtils.kt @@ -86,4 +86,5 @@ fun String.splitOnWhitespace() = split(whitespaceSplitRegex) private val whitespaceSplitRegex = "\\s+".toRegex() // from https://github.com/mathiasbynens/emoji-test-regex-pattern, MIT license +// matches single emojis only private val emoRegex = "[#*0-9]\\x{FE0F}?\\x{20E3}|[\\xA9\\xAE\\x{203C}\\x{2049}\\x{2122}\\x{2139}\\x{2194}-\\x{2199}\\x{21A9}\\x{21AA}\\x{231A}\\x{231B}\\x{2328}\\x{23CF}\\x{23ED}-\\x{23EF}\\x{23F1}\\x{23F2}\\x{23F8}-\\x{23FA}\\x{24C2}\\x{25AA}\\x{25AB}\\x{25B6}\\x{25C0}\\x{25FB}\\x{25FC}\\x{25FE}\\x{2600}-\\x{2604}\\x{260E}\\x{2611}\\x{2614}\\x{2615}\\x{2618}\\x{2620}\\x{2622}\\x{2623}\\x{2626}\\x{262A}\\x{262E}\\x{262F}\\x{2638}-\\x{263A}\\x{2640}\\x{2642}\\x{2648}-\\x{2653}\\x{265F}\\x{2660}\\x{2663}\\x{2665}\\x{2666}\\x{2668}\\x{267B}\\x{267E}\\x{267F}\\x{2692}\\x{2694}-\\x{2697}\\x{2699}\\x{269B}\\x{269C}\\x{26A0}\\x{26A7}\\x{26AA}\\x{26B0}\\x{26B1}\\x{26BD}\\x{26BE}\\x{26C4}\\x{26C8}\\x{26CF}\\x{26D1}\\x{26E9}\\x{26F0}-\\x{26F5}\\x{26F7}\\x{26F8}\\x{26FA}\\x{2702}\\x{2708}\\x{2709}\\x{270F}\\x{2712}\\x{2714}\\x{2716}\\x{271D}\\x{2721}\\x{2733}\\x{2734}\\x{2744}\\x{2747}\\x{2757}\\x{2763}\\x{27A1}\\x{2934}\\x{2935}\\x{2B05}-\\x{2B07}\\x{2B1B}\\x{2B1C}\\x{2B55}\\x{3030}\\x{303D}\\x{3297}\\x{3299}\\x{1F004}\\x{1F170}\\x{1F171}\\x{1F17E}\\x{1F17F}\\x{1F202}\\x{1F237}\\x{1F321}\\x{1F324}-\\x{1F32C}\\x{1F336}\\x{1F37D}\\x{1F396}\\x{1F397}\\x{1F399}-\\x{1F39B}\\x{1F39E}\\x{1F39F}\\x{1F3CD}\\x{1F3CE}\\x{1F3D4}-\\x{1F3DF}\\x{1F3F5}\\x{1F3F7}\\x{1F43F}\\x{1F4FD}\\x{1F549}\\x{1F54A}\\x{1F56F}\\x{1F570}\\x{1F573}\\x{1F576}-\\x{1F579}\\x{1F587}\\x{1F58A}-\\x{1F58D}\\x{1F5A5}\\x{1F5A8}\\x{1F5B1}\\x{1F5B2}\\x{1F5BC}\\x{1F5C2}-\\x{1F5C4}\\x{1F5D1}-\\x{1F5D3}\\x{1F5DC}-\\x{1F5DE}\\x{1F5E1}\\x{1F5E3}\\x{1F5E8}\\x{1F5EF}\\x{1F5F3}\\x{1F5FA}\\x{1F6CB}\\x{1F6CD}-\\x{1F6CF}\\x{1F6E0}-\\x{1F6E5}\\x{1F6E9}\\x{1F6F0}\\x{1F6F3}]\\x{FE0F}?|[\\x{261D}\\x{270C}\\x{270D}\\x{1F574}\\x{1F590}][\\x{FE0F}\\x{1F3FB}-\\x{1F3FF}]?|[\\x{26F9}\\x{1F3CB}\\x{1F3CC}\\x{1F575}][\\x{FE0F}\\x{1F3FB}-\\x{1F3FF}]?(?:\\x{200D}[\\x{2640}\\x{2642}]\\x{FE0F}?)?|[\\x{270A}\\x{270B}\\x{1F385}\\x{1F3C2}\\x{1F3C7}\\x{1F442}\\x{1F443}\\x{1F446}-\\x{1F450}\\x{1F466}\\x{1F467}\\x{1F46B}-\\x{1F46D}\\x{1F472}\\x{1F474}-\\x{1F476}\\x{1F478}\\x{1F47C}\\x{1F483}\\x{1F485}\\x{1F48F}\\x{1F491}\\x{1F4AA}\\x{1F57A}\\x{1F595}\\x{1F596}\\x{1F64C}\\x{1F64F}\\x{1F6C0}\\x{1F6CC}\\x{1F90C}\\x{1F90F}\\x{1F918}-\\x{1F91F}\\x{1F930}-\\x{1F934}\\x{1F936}\\x{1F977}\\x{1F9B5}\\x{1F9B6}\\x{1F9BB}\\x{1F9D2}\\x{1F9D3}\\x{1F9D5}\\x{1FAC3}-\\x{1FAC5}\\x{1FAF0}\\x{1FAF2}-\\x{1FAF8}][\\x{1F3FB}-\\x{1F3FF}]?|[\\x{1F3C3}\\x{1F6B6}\\x{1F9CE}][\\x{1F3FB}-\\x{1F3FF}]?(?:\\x{200D}(?:[\\x{2640}\\x{2642}]\\x{FE0F}?(?:\\x{200D}\\x{27A1}\\x{FE0F}?)?|\\x{27A1}\\x{FE0F}?))?|[\\x{1F3C4}\\x{1F3CA}\\x{1F46E}\\x{1F470}\\x{1F471}\\x{1F473}\\x{1F477}\\x{1F481}\\x{1F482}\\x{1F486}\\x{1F487}\\x{1F645}-\\x{1F647}\\x{1F64B}\\x{1F64D}\\x{1F64E}\\x{1F6A3}\\x{1F6B4}\\x{1F6B5}\\x{1F926}\\x{1F935}\\x{1F937}-\\x{1F939}\\x{1F93D}\\x{1F93E}\\x{1F9B8}\\x{1F9B9}\\x{1F9CD}\\x{1F9CF}\\x{1F9D4}\\x{1F9D6}-\\x{1F9DD}][\\x{1F3FB}-\\x{1F3FF}]?(?:\\x{200D}[\\x{2640}\\x{2642}]\\x{FE0F}?)?|[\\x{1F46F}\\x{1F9DE}\\x{1F9DF}](?:\\x{200D}[\\x{2640}\\x{2642}]\\x{FE0F}?)?|[\\x{23E9}-\\x{23EC}\\x{23F0}\\x{23F3}\\x{25FD}\\x{2693}\\x{26A1}\\x{26AB}\\x{26C5}\\x{26CE}\\x{26D4}\\x{26EA}\\x{26FD}\\x{2705}\\x{2728}\\x{274C}\\x{274E}\\x{2753}-\\x{2755}\\x{2795}-\\x{2797}\\x{27B0}\\x{27BF}\\x{2B50}\\x{1F0CF}\\x{1F18E}\\x{1F191}-\\x{1F19A}\\x{1F201}\\x{1F21A}\\x{1F22F}\\x{1F232}-\\x{1F236}\\x{1F238}-\\x{1F23A}\\x{1F250}\\x{1F251}\\x{1F300}-\\x{1F320}\\x{1F32D}-\\x{1F335}\\x{1F337}-\\x{1F343}\\x{1F345}-\\x{1F34A}\\x{1F34C}-\\x{1F37C}\\x{1F37E}-\\x{1F384}\\x{1F386}-\\x{1F393}\\x{1F3A0}-\\x{1F3C1}\\x{1F3C5}\\x{1F3C6}\\x{1F3C8}\\x{1F3C9}\\x{1F3CF}-\\x{1F3D3}\\x{1F3E0}-\\x{1F3F0}\\x{1F3F8}-\\x{1F407}\\x{1F409}-\\x{1F414}\\x{1F416}-\\x{1F425}\\x{1F427}-\\x{1F43A}\\x{1F43C}-\\x{1F43E}\\x{1F440}\\x{1F444}\\x{1F445}\\x{1F451}-\\x{1F465}\\x{1F46A}\\x{1F479}-\\x{1F47B}\\x{1F47D}-\\x{1F480}\\x{1F484}\\x{1F488}-\\x{1F48E}\\x{1F490}\\x{1F492}-\\x{1F4A9}\\x{1F4AB}-\\x{1F4FC}\\x{1F4FF}-\\x{1F53D}\\x{1F54B}-\\x{1F54E}\\x{1F550}-\\x{1F567}\\x{1F5A4}\\x{1F5FB}-\\x{1F62D}\\x{1F62F}-\\x{1F634}\\x{1F637}-\\x{1F641}\\x{1F643}\\x{1F644}\\x{1F648}-\\x{1F64A}\\x{1F680}-\\x{1F6A2}\\x{1F6A4}-\\x{1F6B3}\\x{1F6B7}-\\x{1F6BF}\\x{1F6C1}-\\x{1F6C5}\\x{1F6D0}-\\x{1F6D2}\\x{1F6D5}-\\x{1F6D7}\\x{1F6DC}-\\x{1F6DF}\\x{1F6EB}\\x{1F6EC}\\x{1F6F4}-\\x{1F6FC}\\x{1F7E0}-\\x{1F7EB}\\x{1F7F0}\\x{1F90D}\\x{1F90E}\\x{1F910}-\\x{1F917}\\x{1F920}-\\x{1F925}\\x{1F927}-\\x{1F92F}\\x{1F93A}\\x{1F93F}-\\x{1F945}\\x{1F947}-\\x{1F976}\\x{1F978}-\\x{1F9B4}\\x{1F9B7}\\x{1F9BA}\\x{1F9BC}-\\x{1F9CC}\\x{1F9D0}\\x{1F9E0}-\\x{1F9FF}\\x{1FA70}-\\x{1FA7C}\\x{1FA80}-\\x{1FA88}\\x{1FA90}-\\x{1FABD}\\x{1FABF}-\\x{1FAC2}\\x{1FACE}-\\x{1FADB}\\x{1FAE0}-\\x{1FAE8}]|\\x{26D3}\\x{FE0F}?(?:\\x{200D}\\x{1F4A5})?|\\x{2764}\\x{FE0F}?(?:\\x{200D}[\\x{1F525}\\x{1FA79}])?|\\x{1F1E6}[\\x{1F1E8}-\\x{1F1EC}\\x{1F1EE}\\x{1F1F1}\\x{1F1F2}\\x{1F1F4}\\x{1F1F6}-\\x{1F1FA}\\x{1F1FC}\\x{1F1FD}\\x{1F1FF}]|\\x{1F1E7}[\\x{1F1E6}\\x{1F1E7}\\x{1F1E9}-\\x{1F1EF}\\x{1F1F1}-\\x{1F1F4}\\x{1F1F6}-\\x{1F1F9}\\x{1F1FB}\\x{1F1FC}\\x{1F1FE}\\x{1F1FF}]|\\x{1F1E8}[\\x{1F1E6}\\x{1F1E8}\\x{1F1E9}\\x{1F1EB}-\\x{1F1EE}\\x{1F1F0}-\\x{1F1F5}\\x{1F1F7}\\x{1F1FA}-\\x{1F1FF}]|\\x{1F1E9}[\\x{1F1EA}\\x{1F1EC}\\x{1F1EF}\\x{1F1F0}\\x{1F1F2}\\x{1F1F4}\\x{1F1FF}]|\\x{1F1EA}[\\x{1F1E6}\\x{1F1E8}\\x{1F1EA}\\x{1F1EC}\\x{1F1ED}\\x{1F1F7}-\\x{1F1FA}]|\\x{1F1EB}[\\x{1F1EE}-\\x{1F1F0}\\x{1F1F2}\\x{1F1F4}\\x{1F1F7}]|\\x{1F1EC}[\\x{1F1E6}\\x{1F1E7}\\x{1F1E9}-\\x{1F1EE}\\x{1F1F1}-\\x{1F1F3}\\x{1F1F5}-\\x{1F1FA}\\x{1F1FC}\\x{1F1FE}]|\\x{1F1ED}[\\x{1F1F0}\\x{1F1F2}\\x{1F1F3}\\x{1F1F7}\\x{1F1F9}\\x{1F1FA}]|\\x{1F1EE}[\\x{1F1E8}-\\x{1F1EA}\\x{1F1F1}-\\x{1F1F4}\\x{1F1F6}-\\x{1F1F9}]|\\x{1F1EF}[\\x{1F1EA}\\x{1F1F2}\\x{1F1F4}\\x{1F1F5}]|\\x{1F1F0}[\\x{1F1EA}\\x{1F1EC}-\\x{1F1EE}\\x{1F1F2}\\x{1F1F3}\\x{1F1F5}\\x{1F1F7}\\x{1F1FC}\\x{1F1FE}\\x{1F1FF}]|\\x{1F1F1}[\\x{1F1E6}-\\x{1F1E8}\\x{1F1EE}\\x{1F1F0}\\x{1F1F7}-\\x{1F1FB}\\x{1F1FE}]|\\x{1F1F2}[\\x{1F1E6}\\x{1F1E8}-\\x{1F1ED}\\x{1F1F0}-\\x{1F1FF}]|\\x{1F1F3}[\\x{1F1E6}\\x{1F1E8}\\x{1F1EA}-\\x{1F1EC}\\x{1F1EE}\\x{1F1F1}\\x{1F1F4}\\x{1F1F5}\\x{1F1F7}\\x{1F1FA}\\x{1F1FF}]|\\x{1F1F4}\\x{1F1F2}|\\x{1F1F5}[\\x{1F1E6}\\x{1F1EA}-\\x{1F1ED}\\x{1F1F0}-\\x{1F1F3}\\x{1F1F7}-\\x{1F1F9}\\x{1F1FC}\\x{1F1FE}]|\\x{1F1F6}\\x{1F1E6}|\\x{1F1F7}[\\x{1F1EA}\\x{1F1F4}\\x{1F1F8}\\x{1F1FA}\\x{1F1FC}]|\\x{1F1F8}[\\x{1F1E6}-\\x{1F1EA}\\x{1F1EC}-\\x{1F1F4}\\x{1F1F7}-\\x{1F1F9}\\x{1F1FB}\\x{1F1FD}-\\x{1F1FF}]|\\x{1F1F9}[\\x{1F1E6}\\x{1F1E8}\\x{1F1E9}\\x{1F1EB}-\\x{1F1ED}\\x{1F1EF}-\\x{1F1F4}\\x{1F1F7}\\x{1F1F9}\\x{1F1FB}\\x{1F1FC}\\x{1F1FF}]|\\x{1F1FA}[\\x{1F1E6}\\x{1F1EC}\\x{1F1F2}\\x{1F1F3}\\x{1F1F8}\\x{1F1FE}\\x{1F1FF}]|\\x{1F1FB}[\\x{1F1E6}\\x{1F1E8}\\x{1F1EA}\\x{1F1EC}\\x{1F1EE}\\x{1F1F3}\\x{1F1FA}]|\\x{1F1FC}[\\x{1F1EB}\\x{1F1F8}]|\\x{1F1FD}\\x{1F1F0}|\\x{1F1FE}[\\x{1F1EA}\\x{1F1F9}]|\\x{1F1FF}[\\x{1F1E6}\\x{1F1F2}\\x{1F1FC}]|\\x{1F344}(?:\\x{200D}\\x{1F7EB})?|\\x{1F34B}(?:\\x{200D}\\x{1F7E9})?|\\x{1F3F3}\\x{FE0F}?(?:\\x{200D}(?:\\x{26A7}\\x{FE0F}?|\\x{1F308}))?|\\x{1F3F4}(?:\\x{200D}\\x{2620}\\x{FE0F}?|\\x{E0067}\\x{E0062}(?:\\x{E0065}\\x{E006E}\\x{E0067}|\\x{E0073}\\x{E0063}\\x{E0074}|\\x{E0077}\\x{E006C}\\x{E0073})\\x{E007F})?|\\x{1F408}(?:\\x{200D}\\x{2B1B})?|\\x{1F415}(?:\\x{200D}\\x{1F9BA})?|\\x{1F426}(?:\\x{200D}[\\x{2B1B}\\x{1F525}])?|\\x{1F43B}(?:\\x{200D}\\x{2744}\\x{FE0F}?)?|\\x{1F441}\\x{FE0F}?(?:\\x{200D}\\x{1F5E8}\\x{FE0F}?)?|\\x{1F468}(?:\\x{200D}(?:[\\x{2695}\\x{2696}\\x{2708}]\\x{FE0F}?|[\\x{1F468}\\x{1F469}]\\x{200D}(?:\\x{1F466}(?:\\x{200D}\\x{1F466})?|\\x{1F467}(?:\\x{200D}[\\x{1F466}\\x{1F467}])?)|[\\x{1F9AF}\\x{1F9BC}\\x{1F9BD}](?:\\x{200D}\\x{27A1}\\x{FE0F}?)?|[\\x{1F33E}\\x{1F373}\\x{1F37C}\\x{1F393}\\x{1F3A4}\\x{1F3A8}\\x{1F3EB}\\x{1F3ED}\\x{1F4BB}\\x{1F4BC}\\x{1F527}\\x{1F52C}\\x{1F680}\\x{1F692}\\x{1F9B0}-\\x{1F9B3}]|\\x{2764}\\x{FE0F}?\\x{200D}(?:\\x{1F48B}\\x{200D})?\\x{1F468}|\\x{1F466}(?:\\x{200D}\\x{1F466})?|\\x{1F467}(?:\\x{200D}[\\x{1F466}\\x{1F467}])?)|\\x{1F3FB}(?:\\x{200D}(?:[\\x{2695}\\x{2696}\\x{2708}]\\x{FE0F}?|[\\x{1F9AF}\\x{1F9BC}\\x{1F9BD}](?:\\x{200D}\\x{27A1}\\x{FE0F}?)?|[\\x{1F33E}\\x{1F373}\\x{1F37C}\\x{1F393}\\x{1F3A4}\\x{1F3A8}\\x{1F3EB}\\x{1F3ED}\\x{1F4BB}\\x{1F4BC}\\x{1F527}\\x{1F52C}\\x{1F680}\\x{1F692}\\x{1F9B0}-\\x{1F9B3}]|\\x{2764}\\x{FE0F}?\\x{200D}(?:\\x{1F48B}\\x{200D})?\\x{1F468}[\\x{1F3FB}-\\x{1F3FF}]|\\x{1F91D}\\x{200D}\\x{1F468}[\\x{1F3FC}-\\x{1F3FF}]))?|\\x{1F3FC}(?:\\x{200D}(?:[\\x{2695}\\x{2696}\\x{2708}]\\x{FE0F}?|[\\x{1F9AF}\\x{1F9BC}\\x{1F9BD}](?:\\x{200D}\\x{27A1}\\x{FE0F}?)?|[\\x{1F33E}\\x{1F373}\\x{1F37C}\\x{1F393}\\x{1F3A4}\\x{1F3A8}\\x{1F3EB}\\x{1F3ED}\\x{1F4BB}\\x{1F4BC}\\x{1F527}\\x{1F52C}\\x{1F680}\\x{1F692}\\x{1F9B0}-\\x{1F9B3}]|\\x{2764}\\x{FE0F}?\\x{200D}(?:\\x{1F48B}\\x{200D})?\\x{1F468}[\\x{1F3FB}-\\x{1F3FF}]|\\x{1F91D}\\x{200D}\\x{1F468}[\\x{1F3FB}\\x{1F3FD}-\\x{1F3FF}]))?|\\x{1F3FD}(?:\\x{200D}(?:[\\x{2695}\\x{2696}\\x{2708}]\\x{FE0F}?|[\\x{1F9AF}\\x{1F9BC}\\x{1F9BD}](?:\\x{200D}\\x{27A1}\\x{FE0F}?)?|[\\x{1F33E}\\x{1F373}\\x{1F37C}\\x{1F393}\\x{1F3A4}\\x{1F3A8}\\x{1F3EB}\\x{1F3ED}\\x{1F4BB}\\x{1F4BC}\\x{1F527}\\x{1F52C}\\x{1F680}\\x{1F692}\\x{1F9B0}-\\x{1F9B3}]|\\x{2764}\\x{FE0F}?\\x{200D}(?:\\x{1F48B}\\x{200D})?\\x{1F468}[\\x{1F3FB}-\\x{1F3FF}]|\\x{1F91D}\\x{200D}\\x{1F468}[\\x{1F3FB}\\x{1F3FC}\\x{1F3FE}\\x{1F3FF}]))?|\\x{1F3FE}(?:\\x{200D}(?:[\\x{2695}\\x{2696}\\x{2708}]\\x{FE0F}?|[\\x{1F9AF}\\x{1F9BC}\\x{1F9BD}](?:\\x{200D}\\x{27A1}\\x{FE0F}?)?|[\\x{1F33E}\\x{1F373}\\x{1F37C}\\x{1F393}\\x{1F3A4}\\x{1F3A8}\\x{1F3EB}\\x{1F3ED}\\x{1F4BB}\\x{1F4BC}\\x{1F527}\\x{1F52C}\\x{1F680}\\x{1F692}\\x{1F9B0}-\\x{1F9B3}]|\\x{2764}\\x{FE0F}?\\x{200D}(?:\\x{1F48B}\\x{200D})?\\x{1F468}[\\x{1F3FB}-\\x{1F3FF}]|\\x{1F91D}\\x{200D}\\x{1F468}[\\x{1F3FB}-\\x{1F3FD}\\x{1F3FF}]))?|\\x{1F3FF}(?:\\x{200D}(?:[\\x{2695}\\x{2696}\\x{2708}]\\x{FE0F}?|[\\x{1F9AF}\\x{1F9BC}\\x{1F9BD}](?:\\x{200D}\\x{27A1}\\x{FE0F}?)?|[\\x{1F33E}\\x{1F373}\\x{1F37C}\\x{1F393}\\x{1F3A4}\\x{1F3A8}\\x{1F3EB}\\x{1F3ED}\\x{1F4BB}\\x{1F4BC}\\x{1F527}\\x{1F52C}\\x{1F680}\\x{1F692}\\x{1F9B0}-\\x{1F9B3}]|\\x{2764}\\x{FE0F}?\\x{200D}(?:\\x{1F48B}\\x{200D})?\\x{1F468}[\\x{1F3FB}-\\x{1F3FF}]|\\x{1F91D}\\x{200D}\\x{1F468}[\\x{1F3FB}-\\x{1F3FE}]))?)?|\\x{1F469}(?:\\x{200D}(?:[\\x{2695}\\x{2696}\\x{2708}]\\x{FE0F}?|[\\x{1F9AF}\\x{1F9BC}\\x{1F9BD}](?:\\x{200D}\\x{27A1}\\x{FE0F}?)?|[\\x{1F33E}\\x{1F373}\\x{1F37C}\\x{1F393}\\x{1F3A4}\\x{1F3A8}\\x{1F3EB}\\x{1F3ED}\\x{1F4BB}\\x{1F4BC}\\x{1F527}\\x{1F52C}\\x{1F680}\\x{1F692}\\x{1F9B0}-\\x{1F9B3}]|\\x{2764}\\x{FE0F}?\\x{200D}(?:\\x{1F48B}\\x{200D})?[\\x{1F468}\\x{1F469}]|\\x{1F466}(?:\\x{200D}\\x{1F466})?|\\x{1F467}(?:\\x{200D}[\\x{1F466}\\x{1F467}])?|\\x{1F469}\\x{200D}(?:\\x{1F466}(?:\\x{200D}\\x{1F466})?|\\x{1F467}(?:\\x{200D}[\\x{1F466}\\x{1F467}])?))|\\x{1F3FB}(?:\\x{200D}(?:[\\x{2695}\\x{2696}\\x{2708}]\\x{FE0F}?|[\\x{1F9AF}\\x{1F9BC}\\x{1F9BD}](?:\\x{200D}\\x{27A1}\\x{FE0F}?)?|[\\x{1F33E}\\x{1F373}\\x{1F37C}\\x{1F393}\\x{1F3A4}\\x{1F3A8}\\x{1F3EB}\\x{1F3ED}\\x{1F4BB}\\x{1F4BC}\\x{1F527}\\x{1F52C}\\x{1F680}\\x{1F692}\\x{1F9B0}-\\x{1F9B3}]|\\x{2764}\\x{FE0F}?\\x{200D}(?:[\\x{1F468}\\x{1F469}]|\\x{1F48B}\\x{200D}[\\x{1F468}\\x{1F469}])[\\x{1F3FB}-\\x{1F3FF}]|\\x{1F91D}\\x{200D}[\\x{1F468}\\x{1F469}][\\x{1F3FC}-\\x{1F3FF}]))?|\\x{1F3FC}(?:\\x{200D}(?:[\\x{2695}\\x{2696}\\x{2708}]\\x{FE0F}?|[\\x{1F9AF}\\x{1F9BC}\\x{1F9BD}](?:\\x{200D}\\x{27A1}\\x{FE0F}?)?|[\\x{1F33E}\\x{1F373}\\x{1F37C}\\x{1F393}\\x{1F3A4}\\x{1F3A8}\\x{1F3EB}\\x{1F3ED}\\x{1F4BB}\\x{1F4BC}\\x{1F527}\\x{1F52C}\\x{1F680}\\x{1F692}\\x{1F9B0}-\\x{1F9B3}]|\\x{2764}\\x{FE0F}?\\x{200D}(?:[\\x{1F468}\\x{1F469}]|\\x{1F48B}\\x{200D}[\\x{1F468}\\x{1F469}])[\\x{1F3FB}-\\x{1F3FF}]|\\x{1F91D}\\x{200D}[\\x{1F468}\\x{1F469}][\\x{1F3FB}\\x{1F3FD}-\\x{1F3FF}]))?|\\x{1F3FD}(?:\\x{200D}(?:[\\x{2695}\\x{2696}\\x{2708}]\\x{FE0F}?|[\\x{1F9AF}\\x{1F9BC}\\x{1F9BD}](?:\\x{200D}\\x{27A1}\\x{FE0F}?)?|[\\x{1F33E}\\x{1F373}\\x{1F37C}\\x{1F393}\\x{1F3A4}\\x{1F3A8}\\x{1F3EB}\\x{1F3ED}\\x{1F4BB}\\x{1F4BC}\\x{1F527}\\x{1F52C}\\x{1F680}\\x{1F692}\\x{1F9B0}-\\x{1F9B3}]|\\x{2764}\\x{FE0F}?\\x{200D}(?:[\\x{1F468}\\x{1F469}]|\\x{1F48B}\\x{200D}[\\x{1F468}\\x{1F469}])[\\x{1F3FB}-\\x{1F3FF}]|\\x{1F91D}\\x{200D}[\\x{1F468}\\x{1F469}][\\x{1F3FB}\\x{1F3FC}\\x{1F3FE}\\x{1F3FF}]))?|\\x{1F3FE}(?:\\x{200D}(?:[\\x{2695}\\x{2696}\\x{2708}]\\x{FE0F}?|[\\x{1F9AF}\\x{1F9BC}\\x{1F9BD}](?:\\x{200D}\\x{27A1}\\x{FE0F}?)?|[\\x{1F33E}\\x{1F373}\\x{1F37C}\\x{1F393}\\x{1F3A4}\\x{1F3A8}\\x{1F3EB}\\x{1F3ED}\\x{1F4BB}\\x{1F4BC}\\x{1F527}\\x{1F52C}\\x{1F680}\\x{1F692}\\x{1F9B0}-\\x{1F9B3}]|\\x{2764}\\x{FE0F}?\\x{200D}(?:[\\x{1F468}\\x{1F469}]|\\x{1F48B}\\x{200D}[\\x{1F468}\\x{1F469}])[\\x{1F3FB}-\\x{1F3FF}]|\\x{1F91D}\\x{200D}[\\x{1F468}\\x{1F469}][\\x{1F3FB}-\\x{1F3FD}\\x{1F3FF}]))?|\\x{1F3FF}(?:\\x{200D}(?:[\\x{2695}\\x{2696}\\x{2708}]\\x{FE0F}?|[\\x{1F9AF}\\x{1F9BC}\\x{1F9BD}](?:\\x{200D}\\x{27A1}\\x{FE0F}?)?|[\\x{1F33E}\\x{1F373}\\x{1F37C}\\x{1F393}\\x{1F3A4}\\x{1F3A8}\\x{1F3EB}\\x{1F3ED}\\x{1F4BB}\\x{1F4BC}\\x{1F527}\\x{1F52C}\\x{1F680}\\x{1F692}\\x{1F9B0}-\\x{1F9B3}]|\\x{2764}\\x{FE0F}?\\x{200D}(?:[\\x{1F468}\\x{1F469}]|\\x{1F48B}\\x{200D}[\\x{1F468}\\x{1F469}])[\\x{1F3FB}-\\x{1F3FF}]|\\x{1F91D}\\x{200D}[\\x{1F468}\\x{1F469}][\\x{1F3FB}-\\x{1F3FE}]))?)?|\\x{1F62E}(?:\\x{200D}\\x{1F4A8})?|\\x{1F635}(?:\\x{200D}\\x{1F4AB})?|\\x{1F636}(?:\\x{200D}\\x{1F32B}\\x{FE0F}?)?|\\x{1F642}(?:\\x{200D}[\\x{2194}\\x{2195}]\\x{FE0F}?)?|\\x{1F93C}(?:[\\x{1F3FB}-\\x{1F3FF}]|\\x{200D}[\\x{2640}\\x{2642}]\\x{FE0F}?)?|\\x{1F9D1}(?:\\x{200D}(?:[\\x{2695}\\x{2696}\\x{2708}]\\x{FE0F}?|[\\x{1F9AF}\\x{1F9BC}\\x{1F9BD}](?:\\x{200D}\\x{27A1}\\x{FE0F}?)?|[\\x{1F33E}\\x{1F373}\\x{1F37C}\\x{1F384}\\x{1F393}\\x{1F3A4}\\x{1F3A8}\\x{1F3EB}\\x{1F3ED}\\x{1F4BB}\\x{1F4BC}\\x{1F527}\\x{1F52C}\\x{1F680}\\x{1F692}\\x{1F9B0}-\\x{1F9B3}]|\\x{1F91D}\\x{200D}\\x{1F9D1}|\\x{1F9D1}\\x{200D}\\x{1F9D2}(?:\\x{200D}\\x{1F9D2})?|\\x{1F9D2}(?:\\x{200D}\\x{1F9D2})?)|\\x{1F3FB}(?:\\x{200D}(?:[\\x{2695}\\x{2696}\\x{2708}]\\x{FE0F}?|[\\x{1F9AF}\\x{1F9BC}\\x{1F9BD}](?:\\x{200D}\\x{27A1}\\x{FE0F}?)?|[\\x{1F33E}\\x{1F373}\\x{1F37C}\\x{1F384}\\x{1F393}\\x{1F3A4}\\x{1F3A8}\\x{1F3EB}\\x{1F3ED}\\x{1F4BB}\\x{1F4BC}\\x{1F527}\\x{1F52C}\\x{1F680}\\x{1F692}\\x{1F9B0}-\\x{1F9B3}]|\\x{2764}\\x{FE0F}?\\x{200D}(?:\\x{1F48B}\\x{200D})?\\x{1F9D1}[\\x{1F3FC}-\\x{1F3FF}]|\\x{1F91D}\\x{200D}\\x{1F9D1}[\\x{1F3FB}-\\x{1F3FF}]))?|\\x{1F3FC}(?:\\x{200D}(?:[\\x{2695}\\x{2696}\\x{2708}]\\x{FE0F}?|[\\x{1F9AF}\\x{1F9BC}\\x{1F9BD}](?:\\x{200D}\\x{27A1}\\x{FE0F}?)?|[\\x{1F33E}\\x{1F373}\\x{1F37C}\\x{1F384}\\x{1F393}\\x{1F3A4}\\x{1F3A8}\\x{1F3EB}\\x{1F3ED}\\x{1F4BB}\\x{1F4BC}\\x{1F527}\\x{1F52C}\\x{1F680}\\x{1F692}\\x{1F9B0}-\\x{1F9B3}]|\\x{2764}\\x{FE0F}?\\x{200D}(?:\\x{1F48B}\\x{200D})?\\x{1F9D1}[\\x{1F3FB}\\x{1F3FD}-\\x{1F3FF}]|\\x{1F91D}\\x{200D}\\x{1F9D1}[\\x{1F3FB}-\\x{1F3FF}]))?|\\x{1F3FD}(?:\\x{200D}(?:[\\x{2695}\\x{2696}\\x{2708}]\\x{FE0F}?|[\\x{1F9AF}\\x{1F9BC}\\x{1F9BD}](?:\\x{200D}\\x{27A1}\\x{FE0F}?)?|[\\x{1F33E}\\x{1F373}\\x{1F37C}\\x{1F384}\\x{1F393}\\x{1F3A4}\\x{1F3A8}\\x{1F3EB}\\x{1F3ED}\\x{1F4BB}\\x{1F4BC}\\x{1F527}\\x{1F52C}\\x{1F680}\\x{1F692}\\x{1F9B0}-\\x{1F9B3}]|\\x{2764}\\x{FE0F}?\\x{200D}(?:\\x{1F48B}\\x{200D})?\\x{1F9D1}[\\x{1F3FB}\\x{1F3FC}\\x{1F3FE}\\x{1F3FF}]|\\x{1F91D}\\x{200D}\\x{1F9D1}[\\x{1F3FB}-\\x{1F3FF}]))?|\\x{1F3FE}(?:\\x{200D}(?:[\\x{2695}\\x{2696}\\x{2708}]\\x{FE0F}?|[\\x{1F9AF}\\x{1F9BC}\\x{1F9BD}](?:\\x{200D}\\x{27A1}\\x{FE0F}?)?|[\\x{1F33E}\\x{1F373}\\x{1F37C}\\x{1F384}\\x{1F393}\\x{1F3A4}\\x{1F3A8}\\x{1F3EB}\\x{1F3ED}\\x{1F4BB}\\x{1F4BC}\\x{1F527}\\x{1F52C}\\x{1F680}\\x{1F692}\\x{1F9B0}-\\x{1F9B3}]|\\x{2764}\\x{FE0F}?\\x{200D}(?:\\x{1F48B}\\x{200D})?\\x{1F9D1}[\\x{1F3FB}-\\x{1F3FD}\\x{1F3FF}]|\\x{1F91D}\\x{200D}\\x{1F9D1}[\\x{1F3FB}-\\x{1F3FF}]))?|\\x{1F3FF}(?:\\x{200D}(?:[\\x{2695}\\x{2696}\\x{2708}]\\x{FE0F}?|[\\x{1F9AF}\\x{1F9BC}\\x{1F9BD}](?:\\x{200D}\\x{27A1}\\x{FE0F}?)?|[\\x{1F33E}\\x{1F373}\\x{1F37C}\\x{1F384}\\x{1F393}\\x{1F3A4}\\x{1F3A8}\\x{1F3EB}\\x{1F3ED}\\x{1F4BB}\\x{1F4BC}\\x{1F527}\\x{1F52C}\\x{1F680}\\x{1F692}\\x{1F9B0}-\\x{1F9B3}]|\\x{2764}\\x{FE0F}?\\x{200D}(?:\\x{1F48B}\\x{200D})?\\x{1F9D1}[\\x{1F3FB}-\\x{1F3FE}]|\\x{1F91D}\\x{200D}\\x{1F9D1}[\\x{1F3FB}-\\x{1F3FF}]))?)?|\\x{1FAF1}(?:\\x{1F3FB}(?:\\x{200D}\\x{1FAF2}[\\x{1F3FC}-\\x{1F3FF}])?|\\x{1F3FC}(?:\\x{200D}\\x{1FAF2}[\\x{1F3FB}\\x{1F3FD}-\\x{1F3FF}])?|\\x{1F3FD}(?:\\x{200D}\\x{1FAF2}[\\x{1F3FB}\\x{1F3FC}\\x{1F3FE}\\x{1F3FF}])?|\\x{1F3FE}(?:\\x{200D}\\x{1FAF2}[\\x{1F3FB}-\\x{1F3FD}\\x{1F3FF}])?|\\x{1F3FF}(?:\\x{200D}\\x{1FAF2}[\\x{1F3FB}-\\x{1F3FE}])?)?".toRegex() diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/AdvancedSettingsFragment.kt b/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/AdvancedSettingsFragment.kt index 61018a46..5939df6f 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/AdvancedSettingsFragment.kt +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/AdvancedSettingsFragment.kt @@ -53,6 +53,7 @@ class AdvancedSettingsFragment : SubScreenFragment() { private var libfile: File? = null private val backupFilePatterns by lazy { listOf( "blacklists/.*\\.txt".toRegex(), + "layouts/.*.(txt|json)".toRegex(), "dicts/.*/.*user\\.dict".toRegex(), "userunigram.*/userunigram.*\\.(body|header)".toRegex(), "UserHistoryDictionary.*/UserHistoryDictionary.*\\.(body|header)".toRegex(), diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/LanguageSettingsDialog.kt b/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/LanguageSettingsDialog.kt index 242115d8..91bb69c7 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/LanguageSettingsDialog.kt +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/LanguageSettingsDialog.kt @@ -4,10 +4,12 @@ package org.dslul.openboard.inputmethod.latin.settings import android.content.Context import android.content.Intent import android.net.Uri +import android.text.InputType import android.text.method.LinkMovementMethod import android.util.TypedValue import android.view.LayoutInflater import android.view.View +import android.widget.EditText import android.widget.ImageView import android.widget.ScrollView import android.widget.TextView @@ -17,10 +19,13 @@ import androidx.core.view.get import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.size +import androidx.core.widget.doAfterTextChanged import org.dslul.openboard.inputmethod.dictionarypack.DictionaryPackConstants import org.dslul.openboard.inputmethod.keyboard.KeyboardLayoutSet +import org.dslul.openboard.inputmethod.keyboard.KeyboardSwitcher import org.dslul.openboard.inputmethod.latin.BinaryDictionaryGetter import org.dslul.openboard.inputmethod.latin.R +import org.dslul.openboard.inputmethod.latin.common.Constants.Subtype.ExtraValue.KEYBOARD_LAYOUT_SET import org.dslul.openboard.inputmethod.latin.common.LocaleUtils import org.dslul.openboard.inputmethod.latin.databinding.LanguageListItemBinding import org.dslul.openboard.inputmethod.latin.databinding.LocaleSettingsDialogBinding @@ -69,7 +74,7 @@ class LanguageSettingsDialog( } private fun fillSubtypesView() { - if (infos.any { it.subtype.isAsciiCapable }) { // currently can only add subtypes for latin keyboards + if (infos.first().subtype.isAsciiCapable) { binding.addSubtype.setOnClickListener { val layouts = context.resources.getStringArray(R.array.predefined_layouts) .filterNot { layoutName -> infos.any { SubtypeLocaleUtils.getKeyboardLayoutSetName(it.subtype) == layoutName } } @@ -78,19 +83,14 @@ class LanguageSettingsDialog( .setTitle(R.string.keyboard_layout_set) .setItems(displayNames.toTypedArray()) { di, i -> di.dismiss() - val newSubtype = AdditionalSubtypeUtils.createAsciiEmojiCapableAdditionalSubtype(mainLocaleString, layouts[i]) - val newSubtypeInfo = newSubtype.toSubtypeInfo(mainLocale, context, true, infos.first().hasDictionary) // enabled by default, because why else add them - addAdditionalSubtype(prefs, context.resources, newSubtype) - addEnabledSubtype(prefs, newSubtype) - addSubtypeToView(newSubtypeInfo) - infos.add(newSubtypeInfo) - reloadSetting() + addSubtype(layouts[i]) } + .setNeutralButton(R.string.button_title_add_custom_layout) { _, _ -> onClickAddCustomSubtype() } .setNegativeButton(android.R.string.cancel, null) .show() } } else - binding.addSubtype.isGone = true + binding.addSubtype.setOnClickListener { onClickAddCustomSubtype() } // add subtypes infos.sortedBy { it.displayName }.forEach { @@ -98,12 +98,80 @@ class LanguageSettingsDialog( } } + private fun addSubtype(name: String) { + val newSubtype = AdditionalSubtypeUtils.createEmojiCapableAdditionalSubtype(mainLocaleString, name, infos.first().subtype.isAsciiCapable) + val newSubtypeInfo = newSubtype.toSubtypeInfo(mainLocale, context, true, infos.first().hasDictionary) // enabled by default + val displayName = SubtypeLocaleUtils.getKeyboardLayoutSetDisplayName(newSubtype) + val old = infos.firstOrNull { isAdditionalSubtype(it.subtype) && displayName == SubtypeLocaleUtils.getKeyboardLayoutSetDisplayName(it.subtype) } + if (old != null) { + KeyboardSwitcher.getInstance().forceUpdateKeyboardTheme(context) + reloadSetting() + return + } + + addAdditionalSubtype(prefs, context.resources, newSubtype) + addEnabledSubtype(prefs, newSubtype) + addSubtypeToView(newSubtypeInfo) + KeyboardLayoutSet.onKeyboardThemeChanged() + infos.add(newSubtypeInfo) + reloadSetting() + } + + private fun onClickAddCustomSubtype() { + val link = "" + context.getString(R.string.dictionary_link_text) + "" + val message = SpannableStringUtils.fromHtml(context.getString(R.string.message_add_custom_layout, link)) + val dialog = Builder(context) + .setTitle(R.string.button_title_add_custom_layout) + .setMessage(message) + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(R.string.button_copy_existing_layout) { _, _ -> copyLayout() } + .setPositiveButton(R.string.button_load_custom_layout) { _, _ -> fragment?.requestLayoutFile() } + .create() + dialog.show() + (dialog.findViewById(android.R.id.message) as? TextView)?.movementMethod = LinkMovementMethod.getInstance() + } + + private fun copyLayout() { + val layouts = mutableListOf() + val displayNames = mutableListOf() + if (infos.first().subtype.isAsciiCapable) { + layouts.addAll(context.resources.getStringArray(R.array.predefined_layouts)) + layouts.forEach { displayNames.add(SubtypeLocaleUtils.getKeyboardLayoutSetDisplayName(it) ?: it) } + } + infos.forEach { + val layoutSetName = it.subtype.getExtraValueOf(KEYBOARD_LAYOUT_SET) + if (layoutSetName?.startsWith(CUSTOM_LAYOUT_PREFIX) == false) { // don't allow copying custom layout (at least for now) + layouts.add(layoutSetName) + displayNames.add(SubtypeLocaleUtils.getSubtypeDisplayNameInSystemLocale(it.subtype)) + } + } + Builder(context) + .setTitle(R.string.keyboard_layout_set) + .setItems(displayNames.toTypedArray()) { di, i -> + di.dismiss() + val fileName = context.assets.list("layouts")!!.firstOrNull { it.startsWith(layouts[i]) } ?: return@setItems + loadCustomLayout(context.assets.open("layouts${File.separator}$fileName").reader().readText(), layouts[i], mainLocaleString, context) { addSubtype(it) } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onNewLayoutFile(uri: Uri?) { + loadCustomLayout(uri, mainLocaleString, context) { addSubtype(it) } + } + private fun addSubtypeToView(subtype: SubtypeInfo) { val row = LayoutInflater.from(context).inflate(R.layout.language_list_item, listView) + val layoutSetName: String? = subtype.subtype.getExtraValueOf(KEYBOARD_LAYOUT_SET) row.findViewById(R.id.language_name).text = SubtypeLocaleUtils.getKeyboardLayoutSetDisplayName(subtype.subtype) ?: SubtypeLocaleUtils.getSubtypeDisplayNameInSystemLocale(subtype.subtype) - row.findViewById(R.id.language_details).isGone = true + if (layoutSetName?.startsWith(CUSTOM_LAYOUT_PREFIX) == true) { + row.findViewById(R.id.language_details).setText(R.string.edit_layout) + row.findViewById(R.id.language_text).setOnClickListener { editCustomLayout(layoutSetName, context) } + } else { + row.findViewById(R.id.language_details).isGone = true + } row.findViewById(R.id.language_switch).apply { isChecked = subtype.isEnabled isEnabled = !onlySystemLocales @@ -127,6 +195,8 @@ class LanguageSettingsDialog( // can be re-added easily, no need for confirmation dialog binding.subtypes.removeView(row) infos.remove(subtype) + if (layoutSetName?.startsWith(CUSTOM_LAYOUT_PREFIX) == true) + removeCustomLayoutFile(layoutSetName, context) removeAdditionalSubtype(prefs, context.resources, subtype.subtype) removeEnabledSubtype(prefs, subtype.subtype) @@ -353,3 +423,5 @@ private fun getAvailableSecondaryLocales(context: Context, mainLocaleString: Str } return locales } + +private const val LAYOUT_FORMAT_URL = "https://github.com/Helium314/openboard/blob/new/layouts.md" diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/LanguageSettingsFragment.kt b/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/LanguageSettingsFragment.kt index a0ca6de9..1000a8e0 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/LanguageSettingsFragment.kt +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/LanguageSettingsFragment.kt @@ -42,6 +42,12 @@ class LanguageSettingsFragment : Fragment(R.layout.language_settings) { listener?.onNewDictionary(uri) } + private val layoutFilePicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode != Activity.RESULT_OK) return@registerForActivityResult + val uri = it.data?.data ?: return@registerForActivityResult + listener?.onNewLayoutFile(uri) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) sharedPreferences = DeviceProtectedUtils.getSharedPreferences(requireContext()) @@ -180,6 +186,7 @@ class LanguageSettingsFragment : Fragment(R.layout.language_settings) { interface Listener { fun onNewDictionary(uri: Uri?) + fun onNewLayoutFile(uri: Uri?) } private var listener: Listener? = null @@ -195,6 +202,14 @@ class LanguageSettingsFragment : Fragment(R.layout.language_settings) { dictionaryFilePicker.launch(intent) } + fun requestLayoutFile() { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + // todo: any working way to allow only json and text files? + .putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("text/*", "application/octet-stream")) // doesn't allow opening json files with "application/json" + .setType("*/*") + layoutFilePicker.launch(intent) + } } class SubtypeInfo(val displayName: String, val subtype: InputMethodSubtype, var isEnabled: Boolean, var hasDictionary: Boolean) { diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/SubtypeSettings.kt b/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/SubtypeSettings.kt index ef06229b..ff244af0 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/SubtypeSettings.kt +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/settings/SubtypeSettings.kt @@ -16,9 +16,12 @@ import org.dslul.openboard.inputmethod.latin.R import org.dslul.openboard.inputmethod.latin.RichInputMethodManager import org.dslul.openboard.inputmethod.latin.define.DebugFlags import org.dslul.openboard.inputmethod.latin.utils.AdditionalSubtypeUtils +import org.dslul.openboard.inputmethod.latin.utils.CUSTOM_LAYOUT_PREFIX import org.dslul.openboard.inputmethod.latin.utils.DeviceProtectedUtils +import org.dslul.openboard.inputmethod.latin.utils.Log import org.dslul.openboard.inputmethod.latin.utils.SubtypeLocaleUtils import org.xmlpull.v1.XmlPullParser +import java.io.File import java.util.* import kotlin.collections.ArrayList import kotlin.collections.LinkedHashMap @@ -39,15 +42,15 @@ fun getAllAvailableSubtypes(): List { return resourceSubtypesByLocale.values.flatten() + additionalSubtypes } -fun addEnabledSubtype(prefs: SharedPreferences, subtype: InputMethodSubtype) { +fun addEnabledSubtype(prefs: SharedPreferences, newSubtype: InputMethodSubtype) { require(initialized) - val subtypeString = subtype.prefString() + val subtypeString = newSubtype.prefString() val oldSubtypeStrings = prefs.getString(Settings.PREF_ENABLED_INPUT_STYLES, "")!!.split(SUBTYPE_SEPARATOR) val newString = (oldSubtypeStrings + subtypeString).filter { it.isNotBlank() }.toSortedSet().joinToString(SUBTYPE_SEPARATOR) prefs.edit { putString(Settings.PREF_ENABLED_INPUT_STYLES, newString) } - if (subtype !in enabledSubtypes) { - enabledSubtypes.add(subtype) + if (newSubtype !in enabledSubtypes) { + enabledSubtypes.add(newSubtype) enabledSubtypes.sortBy { it.locale() } // for consistent order RichInputMethodManager.getInstance().refreshSubtypeCaches() } @@ -63,8 +66,9 @@ fun removeEnabledSubtype(prefs: SharedPreferences, subtype: InputMethodSubtype) fun addAdditionalSubtype(prefs: SharedPreferences, resources: Resources, subtype: InputMethodSubtype) { val oldAdditionalSubtypesString = Settings.readPrefAdditionalSubtypes(prefs, resources) - val oldAdditionalSubtypes = AdditionalSubtypeUtils.createAdditionalSubtypesArray(oldAdditionalSubtypesString).toSet() - val newAdditionalSubtypesString = AdditionalSubtypeUtils.createPrefSubtypes((oldAdditionalSubtypes + subtype).toTypedArray()) + val additionalSubtypes = AdditionalSubtypeUtils.createAdditionalSubtypesArray(oldAdditionalSubtypesString).toMutableSet() + additionalSubtypes.add(subtype) + val newAdditionalSubtypesString = AdditionalSubtypeUtils.createPrefSubtypes(additionalSubtypes.toTypedArray()) Settings.writePrefAdditionalSubtypes(prefs, newAdditionalSubtypesString) } @@ -143,6 +147,7 @@ fun init(context: Context) { reloadSystemLocales(context) loadResourceSubtypes(context.resources) + removeInvalidCustomSubtypes(context) loadAdditionalSubtypes(context) loadEnabledSubtypes(context) initialized = true @@ -214,6 +219,23 @@ private fun loadAdditionalSubtypes(context: Context) { additionalSubtypes.addAll(subtypes) } +// remove custom subtypes without a layout file +private fun removeInvalidCustomSubtypes(context: Context) { + val prefs = DeviceProtectedUtils.getSharedPreferences(context) + val additionalSubtypes = Settings.readPrefAdditionalSubtypes(prefs, context.resources).split(";") + val customSubtypeFiles by lazy { File(context.filesDir, "layouts").list() } + val subtypesToRemove = mutableListOf() + additionalSubtypes.forEach { + val name = it.substringAfter(":").substringBefore(":") + if (!name.startsWith(CUSTOM_LAYOUT_PREFIX)) return@forEach + if (name !in customSubtypeFiles) + subtypesToRemove.add(it) + } + if (subtypesToRemove.isEmpty()) return + Log.w(TAG, "removing custom subtypes without files: $subtypesToRemove") + Settings.writePrefAdditionalSubtypes(prefs, additionalSubtypes.filterNot { it in subtypesToRemove }.joinToString(";")) +} + // requires loadResourceSubtypes to be called before private fun loadEnabledSubtypes(context: Context) { val prefs = DeviceProtectedUtils.getSharedPreferences(context) @@ -231,8 +253,10 @@ private fun loadEnabledSubtypes(context: Context) { val subtype = subtypesForLocale.firstOrNull { SubtypeLocaleUtils.getKeyboardLayoutSetName(it) == localeAndLayout.last() } ?: additionalSubtypes.firstOrNull { it.locale() == localeAndLayout.first() && SubtypeLocaleUtils.getKeyboardLayoutSetName(it) == localeAndLayout.last() } if (subtype == null) { + val message = "subtype $localeAndLayout could not be loaded" + Log.w(TAG, message) if (DebugFlags.DEBUG_ENABLED) - Toast.makeText(context, "subtype $localeAndLayout could not be loaded", Toast.LENGTH_LONG).show() + Toast.makeText(context, message, Toast.LENGTH_LONG).show() else // don't remove in debug mode removeEnabledSubtype(prefs, localeAndLayout.joinToString(LOCALE_LAYOUT_SEPARATOR)) continue @@ -268,6 +292,7 @@ private val systemSubtypes = mutableListOf() private const val SUBTYPE_SEPARATOR = ";" private const val LOCALE_LAYOUT_SEPARATOR = ":" +private const val TAG = "SubtypeSettings" @Suppress("deprecation") // it's deprecated, but no replacement for API < 24 // todo: subtypes should now have language tags -> use them for api >= 24 diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/AdditionalSubtypeUtils.java b/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/AdditionalSubtypeUtils.java index 1cfca7b1..9cde8b1c 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/AdditionalSubtypeUtils.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/AdditionalSubtypeUtils.java @@ -8,7 +8,6 @@ package org.dslul.openboard.inputmethod.latin.utils; import android.os.Build; import android.text.TextUtils; -import org.dslul.openboard.inputmethod.latin.utils.Log; import android.view.inputmethod.InputMethodSubtype; import org.dslul.openboard.inputmethod.annotations.UsedForTesting; @@ -73,14 +72,12 @@ public final class AdditionalSubtypeUtils { public static InputMethodSubtype createDummyAdditionalSubtype( final String localeString, final String keyboardLayoutSetName) { - return createAdditionalSubtypeInternal(localeString, keyboardLayoutSetName, - false /* isAsciiCapable */, false /* isEmojiCapable */); + return createAdditionalSubtypeInternal(localeString, keyboardLayoutSetName, false, false); } - public static InputMethodSubtype createAsciiEmojiCapableAdditionalSubtype( - final String localeString, final String keyboardLayoutSetName) { - return createAdditionalSubtypeInternal(localeString, keyboardLayoutSetName, - true /* isAsciiCapable */, true /* isEmojiCapable */); + public static InputMethodSubtype createEmojiCapableAdditionalSubtype( + final String localeString, final String keyboardLayoutSetName, final boolean asciiCapable) { + return createAdditionalSubtypeInternal(localeString, keyboardLayoutSetName, asciiCapable, true); } public static String getPrefSubtype(final InputMethodSubtype subtype) { @@ -120,11 +117,10 @@ public final class AdditionalSubtypeUtils { } final String localeString = elems[INDEX_OF_LOCALE]; final String keyboardLayoutSetName = elems[INDEX_OF_KEYBOARD_LAYOUT]; - // Here we assume that all the additional subtypes have AsciiCapable and EmojiCapable. - // This is actually what the setting dialog for additional subtype is doing. - final InputMethodSubtype subtype = createAsciiEmojiCapableAdditionalSubtype( - localeString, keyboardLayoutSetName); - if (subtype.getNameResId() == SubtypeLocaleUtils.UNKNOWN_KEYBOARD_LAYOUT) { + final boolean asciiCapable = ScriptUtils.getScriptFromSpellCheckerLocale(LocaleUtils.constructLocaleFromString(localeString)) == ScriptUtils.SCRIPT_LATIN; + // Here we assume that all the additional subtypes are EmojiCapable + final InputMethodSubtype subtype = createEmojiCapableAdditionalSubtype(localeString, keyboardLayoutSetName, asciiCapable); + if (subtype.getNameResId() == SubtypeLocaleUtils.UNKNOWN_KEYBOARD_LAYOUT && !keyboardLayoutSetName.startsWith(CustomLayoutUtilsKt.CUSTOM_LAYOUT_PREFIX)) { // Skip unknown keyboard layout subtype. This may happen when predefined keyboard // layout has been removed. return null; diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/CustomLayoutUtils.kt b/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/CustomLayoutUtils.kt new file mode 100644 index 00000000..6a896002 --- /dev/null +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/CustomLayoutUtils.kt @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: GPL-3.0-only +package org.dslul.openboard.inputmethod.latin.utils + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import android.text.InputType +import android.widget.EditText +import androidx.appcompat.app.AlertDialog +import androidx.core.widget.doAfterTextChanged +import org.dslul.openboard.inputmethod.keyboard.Key +import org.dslul.openboard.inputmethod.keyboard.KeyboardId +import org.dslul.openboard.inputmethod.keyboard.KeyboardLayoutSet +import org.dslul.openboard.inputmethod.keyboard.KeyboardSwitcher +import org.dslul.openboard.inputmethod.keyboard.internal.KeyboardParams +import org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser.JsonKeyboardParser +import org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser.MORE_KEYS_NORMAL +import org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser.SimpleKeyboardParser +import org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser.addLocaleKeyTextsToParams +import org.dslul.openboard.inputmethod.latin.R +import org.dslul.openboard.inputmethod.latin.settings.infoDialog +import java.io.File +import java.io.IOException +import java.math.BigInteger + +fun loadCustomLayout(uri: Uri?, localeString: String, context: Context, onAdded: (String) -> Unit) { + if (uri == null) + return infoDialog(context, context.getString(R.string.layout_error, "layout file not found")) + val layoutContent: String + try { + val i = context.contentResolver.openInputStream(uri) + layoutContent = i?.use { it.reader().readText() } ?: throw IOException() + } catch (e: IOException) { + return infoDialog(context, context.getString(R.string.layout_error, "cannot read layout file")) + } + + var name = "" + context.contentResolver.query(uri, null, null, null, null).use { + if (it != null && it.moveToFirst()) { + val idx = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (idx >= 0) + name = it.getString(idx).substringBeforeLast(".") + } + } + loadCustomLayout(layoutContent, name, localeString, context, onAdded) +} + +fun loadCustomLayout(layoutContent: String, layoutName: String, localeString: String, context: Context, onAdded: (String) -> Unit) { + var name = layoutName + val isJson = checkLayout(layoutContent, context) + ?: return infoDialog(context, context.getString(R.string.layout_error, "invalid layout file, ${Log.getLog().lastOrNull { it.tag == TAG }?.message}")) + + AlertDialog.Builder(context) + .setTitle(R.string.title_layout_name_select) + .setView(EditText(context).apply { + setText(name) + doAfterTextChanged { name = it.toString() } + setPadding(30, 10, 30, 10) + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_NORMAL + }) + .setPositiveButton(android.R.string.ok) { _, _ -> + // name must be encoded to avoid issues with validity of subtype extra string or file name + name = "$CUSTOM_LAYOUT_PREFIX${localeString}.${encodeBase36(name)}.${if (isJson) "json" else "txt"}" + val file = getFile(name, context) + if (file.exists()) + file.delete() + file.parentFile?.mkdir() + file.writeText(layoutContent) + onAdded(name) + } + .show() +} + +private fun checkLayout(layoutContent: String, context: Context): Boolean? { + val params = KeyboardParams() + params.mId = KeyboardLayoutSet.getFakeKeyboardId(KeyboardId.ELEMENT_ALPHABET) + params.mMoreKeyTypes.add(MORE_KEYS_LAYOUT) + addLocaleKeyTextsToParams(context, params, MORE_KEYS_NORMAL) + try { + val keys = JsonKeyboardParser(params, context).parseLayoutString(layoutContent) + if (!checkKeys(keys)) + return null + return true + } catch (e: Exception) { Log.w(TAG, "error parsing custom json layout", e) } + try { + val keys = SimpleKeyboardParser(params, context).parseLayoutString(layoutContent) + if (!checkKeys(keys)) + return null + return false + } catch (e: Exception) { Log.w(TAG, "error parsing custom simple layout", e) } + return null +} + +private fun checkKeys(keys: List>): Boolean { + if (keys.isEmpty() || keys.any { it.isEmpty() }) { + Log.w(TAG, "empty rows") + return false + } + if (keys.size > 8) { + Log.w(TAG, "too many rows") + return false + } + if (keys.any { it.size > 20 }) { + Log.w(TAG, "too many keys in one row") + return false + } + if (keys.any { it.any { ((it.mLabel?.length ?: 0) > 6) } }) { + Log.w(TAG, "too long text on key") + return false + } + if (keys.any { it.any { (it.mMoreKeys?.size ?: 0) > 20 } }) { + Log.w(TAG, "too many popup keys on a key") + return false + } + if (keys.any { it.any { it.mMoreKeys?.any { (it.mLabel?.length ?: 0) > 10 } == true } }) { + Log.w(TAG, "too long text on popup key") + return false + } + return true +} + +private fun getFile(layoutName: String, context: Context) = + File(context.filesDir, "layouts${File.separator}$layoutName") + +// undo the name changes in loadCustomLayout when clicking ok +fun getLayoutDisplayName(layoutName: String) = + try { + decodeBase36(layoutName.substringAfter(CUSTOM_LAYOUT_PREFIX).substringAfter(".").substringBeforeLast(".")) + } catch (_: NumberFormatException) { + layoutName + } + +fun removeCustomLayoutFile(layoutName: String, context: Context) { + getFile(layoutName, context).delete() +} + +fun editCustomLayout(layoutName: String, context: Context, startContent: String? = null) { + val file = getFile(layoutName, context) + val editText = EditText(context).apply { + setText(startContent ?: file.readText()) + } + AlertDialog.Builder(context) + .setTitle(getLayoutDisplayName(layoutName)) + .setView(editText) + .setPositiveButton(R.string.save) { _, _ -> + val content = editText.text.toString() + val isJson = checkLayout(content, context) + if (isJson == null) { + editCustomLayout(layoutName, context, content) + infoDialog(context, context.getString(R.string.layout_error, Log.getLog().lastOrNull { it.tag == TAG }?.message)) + } else { + val wasJson = file.name.substringAfterLast(".") == "json" + file.writeText(content) + if (isJson != wasJson) // unlikely to be needed, but better be safe + file.renameTo(File(file.absolutePath.substringBeforeLast(".") + if (isJson) "json" else "txt")) + KeyboardSwitcher.getInstance().forceUpdateKeyboardTheme(context) + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() +} + +private fun encodeBase36(string: String): String = BigInteger(string.toByteArray()).toString(36) + +private fun decodeBase36(string: String) = BigInteger(string, 36).toByteArray().decodeToString() + +// this goes into prefs and file names, so do not change! +const val CUSTOM_LAYOUT_PREFIX = "custom." +private const val TAG = "CustomLayoutUtils" diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/ScriptUtils.java b/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/ScriptUtils.java index 24327f4b..6ebd3cca 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/ScriptUtils.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/ScriptUtils.java @@ -73,7 +73,6 @@ public class ScriptUtils { mLanguageCodeToScriptCode.put("ko", SCRIPT_HANGUL); mLanguageCodeToScriptCode.put("hi", SCRIPT_DEVANAGARI); mLanguageCodeToScriptCode.put("kn", SCRIPT_KANNADA); - mLanguageCodeToScriptCode.put("kh", SCRIPT_KHMER); mLanguageCodeToScriptCode.put("mr", SCRIPT_DEVANAGARI); mLanguageCodeToScriptCode.put("mn", SCRIPT_CYRILLIC); mLanguageCodeToScriptCode.put("be", SCRIPT_CYRILLIC); diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/SubtypeLocaleUtils.java b/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/SubtypeLocaleUtils.java index 51002636..3cbf35e0 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/SubtypeLocaleUtils.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/SubtypeLocaleUtils.java @@ -283,6 +283,8 @@ public final class SubtypeLocaleUtils { @Nullable public static String getKeyboardLayoutSetDisplayName(@NonNull final String layoutName) { + if (layoutName.startsWith(CustomLayoutUtilsKt.CUSTOM_LAYOUT_PREFIX)) + return CustomLayoutUtilsKt.getLayoutDisplayName(layoutName); return sKeyboardLayoutToDisplayNameMap.get(layoutName); } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 84b188b9..a22737c4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -421,8 +421,22 @@ disposition rather than other common dispositions for Latin languages. [CHAR LIM Save Language - + Layout + + Add custom layout + + Select a file in a compatible format. Information about the formats is available %s. + + Load file + + Copy existing layout + + Set layout name + + Layout error: %s + + Tap to edit raw layout Keypress vibration duration diff --git a/app/src/test/java/org/dslul/openboard/inputmethod/latin/ParserTest.kt b/app/src/test/java/org/dslul/openboard/inputmethod/latin/ParserTest.kt new file mode 100644 index 00000000..8b059406 --- /dev/null +++ b/app/src/test/java/org/dslul/openboard/inputmethod/latin/ParserTest.kt @@ -0,0 +1,101 @@ +package org.dslul.openboard.inputmethod.latin + +import org.dslul.openboard.inputmethod.keyboard.KeyboardId +import org.dslul.openboard.inputmethod.keyboard.KeyboardLayoutSet +import org.dslul.openboard.inputmethod.keyboard.internal.KeyboardParams +import org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser.MORE_KEYS_NORMAL +import org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser.SimpleKeyboardParser +import org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser.addLocaleKeyTextsToParams +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLog + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ + ShadowInputMethodManager2::class, +]) +class ParserTest { + private lateinit var latinIME: LatinIME + + @Before + fun setUp() { + latinIME = Robolectric.setupService(LatinIME::class.java) + ShadowLog.setupLogging() + ShadowLog.stream = System.out + } + + @Test fun simpleParser() { + val params = KeyboardParams() + params.mId = KeyboardLayoutSet.getFakeKeyboardId(KeyboardId.ELEMENT_ALPHABET) + addLocaleKeyTextsToParams(latinIME, params, MORE_KEYS_NORMAL) + val layoutStrings = listOf( +""" +a +b +c + +d +e +f +""", // normal +""" +a +b +c + +d +e +f +""", // spaces in the empty line +""" +a +b +c + +d +e +f +""".replace("\n", "\r\n"), // windows file endings +""" +a +b +c + + +d +e +f + +""", // too many newlines +""" +a +b x +c v + +d +e +f +""", // spaces in the end +""" +a +b +c + +d +e +f""", // no newline at the end + ) + val wantedKeyLabels = listOf(listOf("a", "b", "c"), listOf("d", "e", "f")) + layoutStrings.forEachIndexed { i, layout -> + println(i) + val keyLabels = SimpleKeyboardParser(params, latinIME).parseCoreLayout(layout).map { it.map { it.label } } + assertEquals(wantedKeyLabels, keyLabels) + } + } +} diff --git a/layouts.md b/layouts.md index a5ae6820..981d6fab 100644 --- a/layouts.md +++ b/layouts.md @@ -1,12 +1,26 @@ -# Layouts +A compilation of information about the layout formats usable in this app. -(WIP) information about the layout format +There are two distinct formats: +* the _simple_ format is a text file with one key per line, and two consecutive line breaks indicating a switch to the next row, [example](app/src/main/assets/layouts/qwerty.txt) +* the _json_ format taken from [FlorisBoard](https://github.com/florisboard/florisboard/blob/master/CONTRIBUTING.md#adding-the-layout), but only "normal" keys are supported (i.e. no action keys and similar), [example](app/src/main/assets/layouts/azerty.json) -## simple -One key per line, two consecutive newlines mark a row end. -Key format: [label] [moreKeys], all separated by space, e.g. `a 0 + *` will create a key with text a, and the keys `0`, `+`, and `*` on long press. Some characters currently require escape using `\` (todo: add the list, or better add them in code instead of requiring it in the layouts). -Special symbols: `%` (only for language-dependent moreKeys, not user defined, also better use sth like `%%%`) acts as placeholder for normal moreKeys. `$$$` will be replaced by currency (or default to `$`). -Language-dependent moreKeys should never contain "special" moreKeys, i.e. those starting with `!` (exception for `punctuation`) +## General notes +Adding too many keys or too long texts will make the keyboard look awkward or broken, and even crash the app under some specific conditions. +There are some sanity checks when adding a layout to avoid such issues, but they do not cover all possible cases. +Further there is no check whether the layout actually contains characters of the selected language. -## json -Character layouts from FlorisBoard, but missing code or label will be determined automatically. And not everything supported... +If you use an external glide typing library, you likely will have issues if your layout contains duplicate keys, or keys with text longer than a single letter. + +There are special key labels that are intended for internal use only, but can (currently) be set on custom layouts too. An example is `!icon/previous_key|!code/key_action_previous`, so it's unlikely you will stumble upon issues here when not intentionally provoking it. +One special label that might be wanted though is `$$$`, which will be replaced by the local currency. + +## Simple format +* One key per line + * Key format: [label] [popup keys], all separated by space, e.g. `a 0 + *` will create a key with text `a`, and the keys `0`, `+`, and `*` on long press +* Two consecutive newlines mark beginning of a new row + +## Json format +* You can use character layouts from FlorisBoard +* There is no need for specifying a code, it will be determined from the label automatically + * Specify it if you want key label and code to be different +* You can add a _labelFlag_ to a key for some specific effects, see [here](app/src/main/res/values/attrs.xml) in the section _keyLabelFlags_ for names and numeric values