Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .idea/AndroidProjectSystem.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions .idea/deploymentTargetSelector.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions .idea/runConfigurations.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

111 changes: 111 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when
working with code in this repository.

## 프로젝트 개요

**OpenMoa**는 삼성 모아키 한국어 키보드를 재구현한 오픈소스 Android IME(입력기)입니다.
자음 키를 누른 채 방향으로 드래그하여 모음을 입력하는 제스처 기반 한글 입력 방식을 사용합니다.

- 패키지: `pe.aioo.openmoa`

## 빌드 및 테스트 명령어

```bash
# 유닛 테스트 실행 (CI에서도 동일하게 사용)
./gradlew testDebugUnitTest

# 기기 연결 테스트 실행
./gradlew connectedAndroidTest

# 디버그 APK 빌드
./gradlew assembleDebug
```

CI는 GitHub Actions에서 JDK 21 + Ubuntu 환경으로 `testDebugUnitTest`를 실행합니다.

## 아키텍처

### 핵심 레이어

**1. IME 서비스 (`OpenMoaIME.kt`)**

- `InputMethodService`를 상속한 메인 서비스
- 10가지 키보드 모드(`IMEMode` enum) 관리: 한국어/영어 × 기본/특수문자/숫자/방향키/전화번호패드
- `KeyboardFrameLayout`에서 올라오는 `BaseKeyMessage`(문자 또는 특수키) 수신 후 InputConnection에 전달
- Koin DI로 `Config` 인스턴스를 주입받음

**2. 한글 조합 엔진 (`hangul/`)**

- `HangulAssembler`: 자음·모음 조합, 복합 자음/모음 처리, 아래아(ㆍ, ᆢ) 사용.
- HangulParser 라이브러리(소스 포함)를 활용해 유효성 검증
- `MoeumGestureProcessor`: 8방향 제스처 시퀀스를 모음으로 변환

**3. 뷰 레이어 (`view/`)**

- `KeyboardFrameLayout`: 키보드 레이아웃 전환 컨테이너
- `keyboardview/`:
- `OpenMoaView`(한국어 모아키), `QuertyView`(영어),
- `ArrowView`, `NumberView`, `PhoneView`, `PunctuationView`
- `keytouchlistener/`: 키 유형별 터치 핸들러 (아래 참조)

**4. 터치 리스너 계층**

- `BaseKeyTouchListener` (추상): 모든 리스너의 기반
- `JaumKeyTouchListener`: 자음 키 + 제스처 감지 (`atan2` 각도 계산, 50px 임계값)
- `FunctionalKeyTouchListener`: 상태 변경 키 (shift, 모드 전환)
- `SimpleKeyTouchListener`: 단순 단일 동작 키
- `CrossKeyTouchListener`: 방향키
- `RepeatKeyTouchListener`: 길게 누르면 반복되는 키

### 한글 입력 플로우

1. 사용자가 자음 키 누름 → `JaumKeyTouchListener`가 드래그 방향 감지
2. `MoeumGestureProcessor`가 제스처 시퀀스 → 모음 결정
3. `HangulAssembler`가 자음+모음 조합 → 조합 중 문자 표시
4. 다음 자음 입력 또는 액션 키 → 문자 확정 후 InputConnection 전달

### IMEMode 확장 시 주의사항

`IMEMode`에 새 항목을 추가하면 `OpenMoaIME.kt` 내 모든 exhaustive `when (imeMode)` 분기에
케이스를 추가해야 합니다. 누락 시 컴파일 오류가 발생합니다. 대상 위치:

- `SpecialKey.LANGUAGE`, `HANJA_NUMBER_PUNCTUATION`, `ARROW` 처리
- `onStartInputView()` 내 `TYPE_CLASS_NUMBER`, `TYPE_CLASS_PHONE` 분기
- `returnFromNonStringKeyboard()`

특정 키보드에서만 진입 가능한 모드는 해당 언어 계열 조건과 쉼표(,)로 묶어 처리합니다.

예) `IME_EMOJI`는 한국어 키보드(`OpenMoaView`)에서만 진입 가능하므로,
대부분의 위 분기에서 `IME_KO_*` 조건들과 함께 묶어 별도 분기 없이 처리합니다.

### 메시지 시스템

키 이벤트는 `LocalBroadcastManager`를 통해 전달:

- `StringKeyMessage`: 문자 키 (일반 문자)
- `SpecialKeyMessage`: 특수 동작 (`SpecialKey` enum - 27가지: BACKSPACE,
ENTER, LANGUAGE, 방향키, COPY/CUT/PASTE 등)

### 설정 (`Config`)

Koin으로 싱글턴 제공:

- `longPressRepeatTime`: 50ms
- `longPressThresholdTime`: 500ms
- `gestureThreshold`: 50px
- `hapticFeedback`: true
- `maxSuggestionCount`: 10

## 주요 파일 위치

| 파일 | 역할 |
|------|------|
| `app/src/main/kotlin/pe/aioo/openmoa/OpenMoaIME.kt` | 메인 IME 서비스 |
| `app/src/main/kotlin/pe/aioo/openmoa/hangul/HangulAssembler.kt` | 한글 자모 조합 엔진 |
| `app/src/main/kotlin/pe/aioo/openmoa/hangul/MoeumGestureProcessor.kt` | 제스처→모음 변환 |
| `app/src/main/kotlin/pe/aioo/openmoa/view/keyboardview/OpenMoaView.kt` | 한국어 키보드 레이아웃 |
| `app/src/main/kotlin/pe/aioo/openmoa/view/keytouchlistener/JaumKeyTouchListener.kt` | 자음+제스처 터치 처리 |
| `app/src/main/kotlin/pe/aioo/openmoa/config/Config.kt` | 설정 데이터 클래스 |
| `app/src/main/res/values/strings.xml` | 모든 UI 문자열 (한국어) |
3 changes: 2 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ plugins {

android {
namespace 'pe.aioo.openmoa'
compileSdk 33
compileSdk 34

defaultConfig {
applicationId "pe.aioo.openmoa"
Expand Down Expand Up @@ -41,6 +41,7 @@ dependencies {
implementation "androidx.autofill:autofill:1.1.0"
implementation 'com.google.android.material:material:1.7.0'
implementation 'io.insert-koin:koin-android:3.3.0'
implementation 'androidx.emoji2:emoji2-emojipicker:1.4.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.4'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
Expand Down
1 change: 1 addition & 0 deletions app/src/main/kotlin/pe/aioo/openmoa/IMEMode.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ enum class IMEMode {
IME_KO_PHONE,
IME_KO_PUNCTUATION,
IME_KO_NUMBER,
IME_EMOJI,
}
37 changes: 27 additions & 10 deletions app/src/main/kotlin/pe/aioo/openmoa/OpenMoaIME.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import androidx.autofill.inline.common.TextViewStyle
import androidx.autofill.inline.common.ViewStyle
import androidx.autofill.inline.v1.InlineSuggestionUi
import androidx.core.content.ContextCompat
import androidx.core.view.isEmpty
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
Expand All @@ -49,6 +50,7 @@ class OpenMoaIME : InputMethodService(), KoinComponent {
private val config: Config by inject()
private val hangulAssembler = HangulAssembler()
private var imeMode = IMEMode.IME_KO
private var previousImeMode = IMEMode.IME_KO
private var composingText = ""

private fun finishComposing() {
Expand All @@ -64,7 +66,7 @@ class OpenMoaIME : InputMethodService(), KoinComponent {
@Suppress("DEPRECATION")
intent.getSerializableExtra(EXTRA_NAME)
}
return if (extra is T) extra else null
return extra as? T
}

private fun sendKeyDownUpEvent(keyCode: Int, metaState: Int = 0, withShift: Boolean = false) {
Expand Down Expand Up @@ -172,7 +174,8 @@ class OpenMoaIME : InputMethodService(), KoinComponent {
IMEMode.IME_KO_PUNCTUATION,
IMEMode.IME_KO_NUMBER,
IMEMode.IME_KO_ARROW,
IMEMode.IME_KO_PHONE -> IMEMode.IME_KO
IMEMode.IME_KO_PHONE,
IMEMode.IME_EMOJI -> IMEMode.IME_KO
IMEMode.IME_EN_PUNCTUATION,
IMEMode.IME_EN_NUMBER,
IMEMode.IME_EN_ARROW,
Expand All @@ -186,7 +189,8 @@ class OpenMoaIME : InputMethodService(), KoinComponent {
IMEMode.IME_KO,
IMEMode.IME_KO_NUMBER,
IMEMode.IME_KO_ARROW,
IMEMode.IME_KO_PHONE -> IMEMode.IME_KO_PUNCTUATION
IMEMode.IME_KO_PHONE,
IMEMode.IME_EMOJI -> IMEMode.IME_KO_PUNCTUATION
IMEMode.IME_EN,
IMEMode.IME_EN_NUMBER,
IMEMode.IME_EN_ARROW,
Expand All @@ -203,7 +207,8 @@ class OpenMoaIME : InputMethodService(), KoinComponent {
IMEMode.IME_KO_NUMBER,
IMEMode.IME_KO_PUNCTUATION,
IMEMode.IME_KO_ARROW,
IMEMode.IME_KO_PHONE -> IMEMode.IME_KO_ARROW
IMEMode.IME_KO_PHONE,
IMEMode.IME_EMOJI -> IMEMode.IME_KO_ARROW
IMEMode.IME_EN,
IMEMode.IME_EN_NUMBER,
IMEMode.IME_EN_PUNCTUATION,
Expand Down Expand Up @@ -297,7 +302,14 @@ class OpenMoaIME : InputMethodService(), KoinComponent {
KeyEvent.KEYCODE_MOVE_HOME, KeyEvent.META_CTRL_ON, true
)
}
SpecialKey.EMOJI -> Unit
SpecialKey.EMOJI -> {
if (imeMode == IMEMode.IME_EMOJI) {
setKeyboard(previousImeMode)
} else {
previousImeMode = imeMode
setKeyboard(IMEMode.IME_EMOJI)
}
}
}
}
is String -> {
Expand Down Expand Up @@ -366,7 +378,8 @@ class OpenMoaIME : InputMethodService(), KoinComponent {
IMEMode.IME_KO_PUNCTUATION,
IMEMode.IME_KO_NUMBER,
IMEMode.IME_KO_ARROW,
IMEMode.IME_KO_PHONE -> setKeyboard(IMEMode.IME_KO)
IMEMode.IME_KO_PHONE,
IMEMode.IME_EMOJI -> setKeyboard(IMEMode.IME_KO)
IMEMode.IME_EN,
IMEMode.IME_EN_PUNCTUATION,
IMEMode.IME_EN_NUMBER,
Expand Down Expand Up @@ -400,6 +413,7 @@ class OpenMoaIME : InputMethodService(), KoinComponent {
val numberView = NumberView(this)
val arrowView = ArrowView(this)
val phoneView = PhoneView(this)
val emojiView = EmojiView(this)
keyboardViews = mapOf(
IMEMode.IME_KO to OpenMoaView(this),
IMEMode.IME_EN to QuertyView(this),
Expand All @@ -411,6 +425,7 @@ class OpenMoaIME : InputMethodService(), KoinComponent {
IMEMode.IME_EN_ARROW to arrowView,
IMEMode.IME_KO_PHONE to phoneView,
IMEMode.IME_EN_PHONE to phoneView,
IMEMode.IME_EMOJI to emojiView,
)
val view = layoutInflater.inflate(R.layout.open_moa_ime, null)
binding = OpenMoaImeBinding.bind(view)
Expand All @@ -429,7 +444,8 @@ class OpenMoaIME : InputMethodService(), KoinComponent {
IMEMode.IME_KO_PUNCTUATION,
IMEMode.IME_KO_NUMBER,
IMEMode.IME_KO_ARROW,
IMEMode.IME_KO_PHONE -> IMEMode.IME_KO_NUMBER
IMEMode.IME_KO_PHONE,
IMEMode.IME_EMOJI -> IMEMode.IME_KO_NUMBER
IMEMode.IME_EN,
IMEMode.IME_EN_PUNCTUATION,
IMEMode.IME_EN_NUMBER,
Expand All @@ -445,7 +461,8 @@ class OpenMoaIME : InputMethodService(), KoinComponent {
IMEMode.IME_KO_PUNCTUATION,
IMEMode.IME_KO_NUMBER,
IMEMode.IME_KO_ARROW,
IMEMode.IME_KO_PHONE -> IMEMode.IME_KO_PHONE
IMEMode.IME_KO_PHONE,
IMEMode.IME_EMOJI -> IMEMode.IME_KO_PHONE
IMEMode.IME_EN,
IMEMode.IME_EN_PUNCTUATION,
IMEMode.IME_EN_NUMBER,
Expand Down Expand Up @@ -581,13 +598,13 @@ class OpenMoaIME : InputMethodService(), KoinComponent {
binding.suggestionStripEndChipGroup.removeAllViews()
binding.suggestionStripLayout.visibility =
if (response.inlineSuggestions.isEmpty()) View.GONE else View.VISIBLE
response.inlineSuggestions.map { inlineSuggestion ->
response.inlineSuggestions.forEach { inlineSuggestion ->
val size = Size(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
)
inlineSuggestion.inflate(this, size, mainExecutor) { view ->
if (inlineSuggestion.info.isPinned) {
if (binding.suggestionStripStartChipGroup.childCount == 0) {
if (binding.suggestionStripStartChipGroup.isEmpty()) {
binding.suggestionStripStartChipGroup.addView(view)
} else {
binding.suggestionStripEndChipGroup.addView(view)
Expand Down
60 changes: 60 additions & 0 deletions app/src/main/kotlin/pe/aioo/openmoa/view/keyboardview/EmojiView.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package pe.aioo.openmoa.view.keyboardview

import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.util.AttributeSet
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import pe.aioo.openmoa.OpenMoaIME
import pe.aioo.openmoa.R
import pe.aioo.openmoa.databinding.EmojiViewBinding
import pe.aioo.openmoa.view.keytouchlistener.RepeatKeyTouchListener
import pe.aioo.openmoa.view.keytouchlistener.SimpleKeyTouchListener
import pe.aioo.openmoa.view.message.SpecialKey
import pe.aioo.openmoa.view.message.SpecialKeyMessage

class EmojiView : ConstraintLayout {

constructor(context: Context) : super(context) {
init()
}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
init()
}
constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(
context,
attrs,
defStyle,
) {
init()
}

private lateinit var binding: EmojiViewBinding

@SuppressLint("ClickableViewAccessibility")
private fun init() {
inflate(context, R.layout.emoji_view, this)
binding = EmojiViewBinding.bind(this)

val broadcastManager = LocalBroadcastManager.getInstance(context)
binding.emojiPickerView.setOnEmojiPickedListener { item ->
broadcastManager.sendBroadcast(
Intent(OpenMoaIME.INTENT_ACTION).apply {
putExtra(OpenMoaIME.EXTRA_NAME, item.emoji)
}
)
}

binding.closeButton.setOnTouchListener(
SimpleKeyTouchListener(context, SpecialKeyMessage(SpecialKey.EMOJI))
)
binding.backspaceKey.setOnTouchListener(
RepeatKeyTouchListener(context, SpecialKeyMessage(SpecialKey.BACKSPACE))
)
binding.enterKey.setOnTouchListener(
SimpleKeyTouchListener(context, SpecialKeyMessage(SpecialKey.ENTER))
)
}

}
Loading