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