2023 Android の IME (キーボード) の作成

# 作成したもの

https://www.youtube.com/watch?v=x0J2aIlWJxc&ab_channel=KazumaNaka

### [Play store](https://play.google.com/store/apps/details?id=com.kazumaproject.markdownhelperkeyboard)
https://play.google.com/store/apps/details?id=com.kazumaproject.markdownhelperkeyboard

### [Github](https://github.com/KazumaProject/JapaneseKeyboard/tree/master)

https://github.com/KazumaProject/JapaneseKeyboard/tree/master

# 参考にした記事

- [InputMethodService Android Developers](https://developer.android.com/reference/android/inputmethodservice/InputMethodService)

- [Create an input method Android Developers](https://developer.android.com/develop/ui/views/touch-and-input/creating-input-method)

- [AndroidX時代のIME作成 (1: 簡単なキーボードの作成)](https://qiita.com/Dooteeen/items/d32446be401096c75712)

# Layout

![Screenshot 2023-09-25 at 5.02.56 AM.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569408/34302f8b-1c1a-cb4d-1a62-9f37338c2d4e.png)

- [親View (FrameLayout)](https://github.com/KazumaProject/JapaneseKeyboard/blob/master/app/src/main/res/layout/main_layout.xml)

- [keyboard_view (ConstraintLayout)](https://github.com/KazumaProject/JapaneseKeyboard/blob/master/app/src/main/res/layout/keyboard_layout.xml)
    - 日本語のテンキーキーボードのレイアウト

- [keyboard_kigou_view (ConstraintLayout)](https://github.com/KazumaProject/JapaneseKeyboard/blob/master/app/src/main/res/layout/kigou_main_layout.xml)
    - 絵文字や顔文字を表示するキーボードのレイアウト

- suggestion_recyclerView (RecyclerView)
    - 変換の候補を表示する RecyclerView

*ConstraintLayout の chainStyle に spread を選択する事で layout のネストを少なくした。*

## キーマップ

[キーマップ](https://github.com/KazumaProject/JapaneseKeyboard/blob/master/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/components/TenKeyInfo.kt)を [こちらの記事](https://qiita.com/Dooteeen/items/d32446be401096c75712) を参考にして作成した。上下左右フリック文字を取得出来るようにした

```kotlin
sealed class TenKeyInfo{

    object Null : TenKeyInfo()

    abstract class TenKeyTapFlickInfo : TenKeyInfo() {
        abstract val tap: Char
        abstract val flickLeft: Char
        abstract val flickTop: Char
        abstract val flickRight: Char
        abstract val flickBottom: Char
    }

    object KeyAJapanese : TenKeyTapFlickInfo() {
        override val tap: Char
            get() = 'あ'
        override val flickLeft: Char
            get() = 'い'
        override val flickTop: Char
            get() = 'う'
        override val flickRight: Char
            get() = 'え'
        override val flickBottom: Char
            get() = 'お'
    }

    object KeyKAJapanese : TenKeyTapFlickInfo() {
        override val tap: Char
            get() = 'か'
        override val flickLeft: Char
            get() = 'き'
        override val flickTop: Char
            get() = 'く'
        override val flickRight: Char
            get() = 'け'
        override val flickBottom: Char
            get() = 'こ'
    }

    //以下略
}

```
## 文字入力に使用したロジック

#### 1. setOnTouchListener を使用しタップやフリックを検知する

```kotlin
/** mainView.keyboardView.keySmallLetter is AppCompatImageButton, others are AppCompatButton **/
val keyList = listOf<Any>(
                    mainView.keyboardView.key1,
                    mainView.keyboardView.key2,
                    //以下略
                )

keyList.forEach {
            if (it is AppCompatButton){
                it.setOnTouchListener { v, event ->
                    when(event.action and MotionEvent.ACTION_MASK){
                        MotionEvent.ACTION_DOWN ->{
                            firstXPoint = event.rawX
                            firstYPoint = event.rawY
                            currentTenKeyId = v.id
                            return@setOnTouchListener false
                        }
                        MotionEvent.ACTION_UP ->{
                            val finalX = event.rawX
                            val finalY = event.rawY
                            val distanceX = (finalX - firstXPoint)
                            val distanceY = (finalY - firstYPoint)

                            val keyInfoJapanese = tenKeyMap.getTenKeyInfoJapanese(currentTenKeyId)
                                    if (abs(distanceX) < 100 && abs(distanceY) < 100){
                                        if (keyInfoJapanese is TenKeyInfo.TenKeyTapFlickInfo){
                                            /** Tap タップ **/
                                        }
                                        currentTenKeyId = 0
                                        return@setOnTouchListener false
                                    }

                                    if (abs(distanceX) > abs(distanceY)) {
                                        if (firstXPoint < finalX) {
                                            if (keyInfoJapanese is TenKeyInfo.TenKeyTapFlickInfo){
                                                /** Flick Right 右フリック **/
                                            }
                                        }else {
                                            if (keyInfoJapanese is TenKeyInfo.TenKeyTapFlickInfo){
                                                /** Flick Left 左フリック **/
                                            }
                                        }
                                    }else {
                                        if (firstYPoint < finalY) {
                                            if (keyInfoJapanese is TenKeyInfo.TenKeyTapFlickInfo){
                                                /** Flick Down 下フリック **/
                                            }
                                        }else{
                                            if (keyInfoJapanese is TenKeyInfo.TenKeyTapFlickInfo){
                                                /** Flick Up 上フリック **/
                                            }
                                        }
                                    }
                                    currentTenKeyId = 0
                                    return@setOnTouchListener false
                        }
                        MotionEvent.ACTION_MOVE ->{
                            val finalX = event.rawX
                            val finalY = event.rawY
                            val distanceX = (finalX - firstXPoint)
                            val distanceY = (finalY - firstYPoint)

                            if (abs(distanceX) < 100 && abs(distanceY) < 100){
                                /** Tap タップ **/
                                return@setOnTouchListener false
                            }
                            if (abs(distanceX) > abs(distanceY)) {
                                if (firstXPoint < finalX) {
                                    /** Flick Right 右フリック **/
                                }else{
                                     /** Flick Left 左フリック **/
                                }
                            }else {
                                if (firstYPoint < finalY){
                                   /** Flick Down 下フリック **/
                                }else{
                                   /** Flick Up 上フリック **/
                                }
                            }
                            return@setOnTouchListener false
                        }
                        else -> return@setOnTouchListener true
                    }
                }
                it.setOnLongClickListener { v ->
                    /** Display PopupWindows around long clicked button **/
                    /** ロングクリックした場合、PopupWindows をボタンの周りに表示する **/
                    false
                }
            }
        }
```

#### 2. collectLatest を使用する

_inputString という名前の MutableStateFlow を用意して onCreateInputView() 内で collectLatestする。[InputConnection.setComposingText(CharSequence text, int newCursorPosition)](https://developer.android.com/reference/android/view/inputmethod/InputConnection#setComposingText(java.lang.CharSequence,%20int,%20android.view.inputmethod.TextAttribute)) で文字を入力する

```kotlin
private val _inputString = MutableStateFlow("")

override fun onCreateInputView(): View? {
        val ctx = ContextThemeWrapper(this, R.style.Theme_MarkdownKeyboard)
        mainLayoutBinding = MainLayoutBinding.inflate(LayoutInflater.from(ctx))
        return mainLayoutBinding?.root.apply {
            mainLayoutBinding?.let { mainView ->
                scope.launch {
                    withContext(imeIoDispatcher){
                        _inputString.asStateFlow().collectLatest { inputString ->
                            if (inputString.isNotBlank()) { 
                                /** SpannableString で入力された文字の周りの色を設定する **/
                                val spannableString = SpannableString(inputString)
                                spannableString.apply {
                                    setSpan(BackgroundColorSpan(getColor(R.color.green)),0,inputString.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
                                }
                                /** SpannableString を setComposingText でセットする **/
                                currentInputConnection?.setComposingText(spannableString,1)
                            } else {
                                /** inputString が Empty 場合の処理 **/
                            }

                        }
                    }
                }
            }
        }
    }
```

## カーソルの位置を取得する

#### 1. `onUpdateCursorInfo` を override する

onStartInput で [InputConnection.requestCursorUpdates(int cursorUpdateMode)](https://developer.android.com/reference/android/view/inputmethod/InputConnection#requestCursorUpdates(int,%20int)) を呼び出す。

[CURSOR_UPDATE_MONITOR](https://developer.android.com/reference/android/view/inputmethod/InputConnection#CURSOR_UPDATE_MONITOR) のフラッグを使用してカーソルが移動する度に onUpdateCursorAnchorInfo が呼ばれる。

```kotlin
private var mComposingTextPosition = -1
private var selectionEndtPosition = -1

   override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) {
        super.onStartInput(attribute, restarting)
        currentInputConnection.requestCursorUpdates(InputConnection.CURSOR_UPDATE_MONITOR)
    }

override fun onUpdateCursorAnchorInfo(cursorAnchorInfo: CursorAnchorInfo?) {
        super.onUpdateCursorAnchorInfo(cursorAnchorInfo)
        cursorAnchorInfo?.let { info ->
            selectionEndtPosition = info.selectionEnd
            mComposingTextPosition = info.composingTextStart
        }
}
```

#### 2. ExtractedText を使用する

`onUpdateCursorAnchorInfo` をサポートしていない View を発見した。(2023 年 Android FireFox アプリ内の WebView での Google 検索)

その為、[ExtractedText の startOffset + selectionEnd](https://developer.android.com/reference/android/view/inputmethod/ExtractedText#selectionEnd) でカーソルの位置を取得する。
## Enter Key の処理

Facebook の Messager や Message 等、 Enter Key で改行や独自の処理を行いたい場合がある。

#### 1. InputType に対応する [Sealed Class](https://github.com/KazumaProject/JapaneseKeyboard/blob/master/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/state/InputTypeForIME.kt) を作成する

```kotlin
sealed class InputTypeForIME{
    object None: InputTypeForIME()
    object Text: InputTypeForIME()
    object TextAutoComplete: InputTypeForIME()
    object TextMultiLine: InputTypeForIME()
    object TextImeMultiLine: InputTypeForIME()
    object TextWebEditText: InputTypeForIME()
    object TextSearchView: InputTypeForIME()
    object Number: InputTypeForIME()
    object Phone: InputTypeForIME()
    object Date: InputTypeForIME()
    /** 以下省略 **/
}
```

#### 2. 作成した Sealed class を返す method を作成する

InputType は[こちらのページ](https://developer.android.com/reference/android/text/InputType)を参考にした

```kotlin
fun getCurrentInputTypeForIME(
    inputType: Int
): InputTypeForIME{
    return when(inputType and InputType.TYPE_MASK_CLASS){
        InputType.TYPE_CLASS_TEXT ->{
            when(inputType){
                InputType.TYPE_TEXT_VARIATION_NORMAL -> InputTypeForIME.Text
                InputType.TYPE_TEXT_FLAG_MULTI_LINE -> InputTypeForIME.TextMultiLine
                /**
                 *  180225 : Twitter Tweet & Messenger
                 *  147457 : Facebook Messenger
                 * */
                InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE,180225, 147457 -> InputTypeForIME.TextImeMultiLine
                InputType.TYPE_TEXT_VARIATION_PASSWORD, 129, 225,16545, 209 -> InputTypeForIME.TextPassword
                InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD -> InputTypeForIME.TextVisiblePassword
                /**
                 *  524465 : Twitter (X) Search View
                 * **/
                524465 -> InputTypeForIME.TextSearchView
                /** 以下省略 **/
                else -> InputTypeForIME.Text
            }
        }
        InputType.TYPE_CLASS_NUMBER ->{
            when(inputType){
                InputType.TYPE_NUMBER_VARIATION_NORMAL -> InputTypeForIME.Number
                InputType.TYPE_NUMBER_FLAG_SIGNED -> InputTypeForIME.NumberSigned
                InputType.TYPE_NUMBER_FLAG_DECIMAL -> InputTypeForIME.NumberDecimal
                InputType.TYPE_NUMBER_VARIATION_PASSWORD -> InputTypeForIME.NumberPassword
                180225 -> InputTypeForIME.TextImeMultiLine
                else -> InputTypeForIME.Number
            }
        }
        InputType.TYPE_CLASS_PHONE -> {
            when(inputType){
                180225 -> InputTypeForIME.TextImeMultiLine
                else -> InputTypeForIME.Phone
            }
        }
        InputType.TYPE_CLASS_DATETIME ->{
            when(inputType){
                InputType.TYPE_DATETIME_VARIATION_NORMAL -> InputTypeForIME.Datetime
                InputType.TYPE_DATETIME_VARIATION_DATE -> InputTypeForIME.Date
                InputType.TYPE_DATETIME_VARIATION_TIME -> InputTypeForIME.Time
                180225 -> InputTypeForIME.TextImeMultiLine
                else -> InputTypeForIME.Datetime
            }
        }
        InputType.TYPE_NULL -> InputTypeForIME.None
        else -> InputTypeForIME.None
    }
}
```

#### 3. `onStartInput` で作成した Sealed Class を `EditorInfo.inputType` を引数にして取得する

EditorInfo.TYPE_NULL の場合 raw key event の処理をする。[引用](https://developer.android.com/reference/android/inputmethodservice/InputMethodService#generating-text)

```kotlin
private var currentInputType: InputTypeForIME = InputTypeForIME.Text

override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) {
        super.onStartInput(attribute, restarting)

        attribute?.apply {
            currentInputType = getCurrentInputTypeForIME(inputType)
            when(currentInputType){
                InputTypeForIME.Text,
                InputTypeForIME.TextAutoComplete,
                /** 以下省略 **/
                -> {
                    /** layout type Japanese **/
                }

                InputTypeForIME.TextEditTextInBookingTDBank,
                InputTypeForIME.TextUri,
                InputTypeForIME.TextPostalAddress,
                InputTypeForIME.TextEmailAddress,
                InputTypeForIME.TextWebEmailAddress,
                InputTypeForIME.TextPassword,
                InputTypeForIME.TextVisiblePassword,
                InputTypeForIME.TextWebPassword,
                ->{
                    /** layout type English **/
                }

                InputTypeForIME.None, InputTypeForIME.TextNotCursorUpdate ->{
                    /** text type null **/
                }

                InputTypeForIME.Number,
                InputTypeForIME.NumberDecimal,
                InputTypeForIME.NumberPassword,
                InputTypeForIME.NumberSigned,
                InputTypeForIME.Phone,
                InputTypeForIME.Date,
                InputTypeForIME.Datetime,
                InputTypeForIME.Time, -> {
                    /** layout type Number **/
                }
            }
        }
    }
```

#### 4. Enter Key の処理

```kotlin
private fun setEnterKeyPress(){
        when(currentInputType){
            InputTypeForIME.TextMultiLine,
            InputTypeForIME.TextImeMultiLine ->{
                currentInputConnection?.commitText("\n",1)
            }

            InputTypeForIME.None,
            InputTypeForIME.Text,
            /** 以下省略 **/
            -> {
                currentInputConnection?.apply {
                    sendKeyEvent(
                        KeyEvent(
                            KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER
                        )
                    )
                    sendKeyEvent(
                        KeyEvent(
                            KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER
                        )
                    )
                }
            }

            InputTypeForIME.Number,
            InputTypeForIME.NumberPassword,
            InputTypeForIME.Phone,
            InputTypeForIME.Date, -> {
                currentInputConnection?.performEditorAction(EditorInfo.IME_ACTION_DONE)
            }

            InputTypeForIME.TextSearchView ->{
                currentInputConnection?.performEditorAction(EditorInfo.IME_ACTION_SEARCH)
            }

        }
    }
```


## その他

Google Chrome 等、検索アプリで英語入力すると自動で候補が入力される。
例えば you と打つと youtube となる。
この状態で 新たに [`currentInputConnection.setComposingText`](https://developer.android.com/reference/android/view/inputmethod/InputConnection#setComposingText(java.lang.CharSequence,%20int,%20android.view.inputmethod.TextAttribute)) のメソッドを使用すると [`currentInputConnection.finishComposingText()`](https://developer.android.com/reference/android/view/inputmethod/InputConnection#finishComposingText()) の処理がされてしまう。
その為 `onUpdateCursorAnchorInfo` で ComposeingText が null の場合、_inputString を初期化する処置をとった。

![suggestion.gif](https://github.com/KazumaProject/JapaneseKeyboard/blob/master/images/google_suggestion.gif?raw=true)

```kotlin
override fun onUpdateCursorAnchorInfo(cursorAnchorInfo: CursorAnchorInfo?) {
        super.onUpdateCursorAnchorInfo(cursorAnchorInfo)
        cursorAnchorInfo?.let { info ->
            if (info.composingText == null && _inputString.value.isNotEmpty()){
                if (currentInputType == InputTypeForIME.TextWebSearchView || currentInputType == InputTypeForIME.TextWebSearchViewFireFox) {
                    _inputString.update { EMPTY_STRING }
                }
            }
        }
    }
```

## 最後に

かな漢字変換に [OpenWnn](https://github.com/MozillaReality/OpenWnn) を使用した。最終的には独自のかな漢字変換器を実装して搭載したい。