diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml
new file mode 100644
index 0000000..4a53bee
--- /dev/null
+++ b/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..b268ef3
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index b2c751a..7c2d85a 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,6 +1,9 @@
-
+
+
+
+
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
new file mode 100644
index 0000000..16660f1
--- /dev/null
+++ b/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 94a25f7..35eb1dd 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..3013fda
--- /dev/null
+++ b/CLAUDE.md
@@ -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 문자열 (한국어) |
diff --git a/app/build.gradle b/app/build.gradle
index 3f9ccd1..161057c 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -5,7 +5,7 @@ plugins {
android {
namespace 'pe.aioo.openmoa'
- compileSdk 33
+ compileSdk 34
defaultConfig {
applicationId "pe.aioo.openmoa"
@@ -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'
diff --git a/app/src/main/kotlin/pe/aioo/openmoa/IMEMode.kt b/app/src/main/kotlin/pe/aioo/openmoa/IMEMode.kt
index b6c902c..4fcb680 100644
--- a/app/src/main/kotlin/pe/aioo/openmoa/IMEMode.kt
+++ b/app/src/main/kotlin/pe/aioo/openmoa/IMEMode.kt
@@ -11,4 +11,5 @@ enum class IMEMode {
IME_KO_PHONE,
IME_KO_PUNCTUATION,
IME_KO_NUMBER,
+ IME_EMOJI,
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/pe/aioo/openmoa/OpenMoaIME.kt b/app/src/main/kotlin/pe/aioo/openmoa/OpenMoaIME.kt
index 240803a..e8d0b35 100644
--- a/app/src/main/kotlin/pe/aioo/openmoa/OpenMoaIME.kt
+++ b/app/src/main/kotlin/pe/aioo/openmoa/OpenMoaIME.kt
@@ -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
@@ -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() {
@@ -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) {
@@ -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,
@@ -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,
@@ -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,
@@ -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 -> {
@@ -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,
@@ -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),
@@ -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)
@@ -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,
@@ -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,
@@ -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)
diff --git a/app/src/main/kotlin/pe/aioo/openmoa/view/keyboardview/EmojiView.kt b/app/src/main/kotlin/pe/aioo/openmoa/view/keyboardview/EmojiView.kt
new file mode 100644
index 0000000..d75f74b
--- /dev/null
+++ b/app/src/main/kotlin/pe/aioo/openmoa/view/keyboardview/EmojiView.kt
@@ -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))
+ )
+ }
+
+}
diff --git a/app/src/main/res/layout/emoji_view.xml b/app/src/main/res/layout/emoji_view.xml
new file mode 100644
index 0000000..43b16c5
--- /dev/null
+++ b/app/src/main/res/layout/emoji_view.xml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
index 3cc9f54..c9d6dd9 100644
--- a/app/src/main/res/values-night/colors.xml
+++ b/app/src/main/res/values-night/colors.xml
@@ -5,6 +5,7 @@
#333333
#222222
#FFFFFF
+ #BBBBBB
#5E97EE
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index ab5d630..bc0c98b 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -5,6 +5,7 @@
#FFFFFF
#EEEEEE
#000000
+ #444444
#5E97EE
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 1ad000a..8ceb60c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -21,6 +21,7 @@
8
ㅡ
😀
+ ⌨
끝으로
⏎
5
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index e84d805..c90e233 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -18,4 +18,13 @@
- @color/keyboard_background
+
+
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index e41be9a..eb3d910 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
- id 'com.android.application' version '8.8.1' apply false
- id 'com.android.library' version '8.8.1' apply false
+ id 'com.android.application' version '8.12.0' apply false
+ id 'com.android.library' version '8.12.0' apply false
id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
}
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index df97d72..37f853b 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME