Skip to content

Commit 7e0f1f1

Browse files
committed
feat(android): improve ime pinyin and live settings sync
1 parent f1ec4d5 commit 7e0f1f1

7 files changed

Lines changed: 835 additions & 372 deletions

File tree

clipSync-android/app/src/main/java/com/clipsync/app/ime/ClipSyncInputMethodService.kt

Lines changed: 175 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import kotlinx.coroutines.Dispatchers
4141
import kotlinx.coroutines.SupervisorJob
4242
import kotlinx.coroutines.cancel
4343
import kotlinx.coroutines.flow.collectLatest
44+
import kotlinx.coroutines.flow.distinctUntilChanged
4445
import kotlinx.coroutines.launch
4546
import kotlinx.serialization.json.JsonObject
4647
import kotlinx.serialization.json.booleanOrNull
@@ -60,6 +61,7 @@ class ClipSyncInputMethodService : InputMethodService() {
6061

6162
private var keyboardMode = KeyboardMode.Letters
6263
private var isUppercase = false
64+
private var isCapsLockEnabled = false
6365
private var isChineseMode = false
6466
private var currentConnectionState: ConnectionState = ConnectionState.Disconnected
6567
private var activePanel = ImePanel.Clipboard
@@ -70,6 +72,9 @@ class ClipSyncInputMethodService : InputMethodService() {
7072
private var onlineDevices: List<DeviceEntity> = emptyList()
7173
private var composingPinyin = ""
7274
private var composingCandidates: List<String> = emptyList()
75+
private var candidatePageIndex = 0
76+
private var totalCandidatePages = 0
77+
private var lastShiftTapAt = 0L
7378

7479
private val deleteRepeatHandler = Handler(Looper.getMainLooper())
7580
private val deleteRepeatRunnable = object : Runnable {
@@ -83,6 +88,7 @@ class ClipSyncInputMethodService : InputMethodService() {
8388
private var statusBadgeView: TextView? = null
8489
private var composingBar: LinearLayout? = null
8590
private var composingTextView: TextView? = null
91+
private var composingPageView: TextView? = null
8692
private var candidateRow: LinearLayout? = null
8793
private var candidateScrollView: HorizontalScrollView? = null
8894
private var panelToggleRow: LinearLayout? = null
@@ -110,6 +116,7 @@ class ClipSyncInputMethodService : InputMethodService() {
110116
observeLocalData()
111117
observeConnectionState()
112118
observeMessages()
119+
observeSettings()
113120
}
114121

115122
override fun onCreateInputView(): View {
@@ -282,6 +289,13 @@ class ClipSyncInputMethodService : InputMethodService() {
282289
}
283290
composingBar?.addView(composingTextView)
284291

292+
composingPageView = TextView(this).apply {
293+
setTextColor(Color.parseColor("#64748B"))
294+
setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f)
295+
setPadding(0, dp(4), 0, 0)
296+
}
297+
composingBar?.addView(composingPageView)
298+
285299
candidateScrollView = HorizontalScrollView(this).apply {
286300
isHorizontalScrollBarEnabled = false
287301
setPadding(0, dp(8), 0, 0)
@@ -422,8 +436,10 @@ class ClipSyncInputMethodService : InputMethodService() {
422436
KeySpec.Special(SpecialKey.SystemKeyboard, getString(R.string.ime_action_switch_keyboard), 1.05f),
423437
KeySpec.Special(SpecialKey.ToggleLanguage, resolveLanguageToggleLabel(), 1f),
424438
KeySpec.Special(SpecialKey.ModeNumbers, getString(R.string.ime_action_numbers), 1f),
439+
KeySpec.Special(SpecialKey.CursorLeft, getString(R.string.ime_action_cursor_left), 0.8f),
440+
KeySpec.Special(SpecialKey.Space, resolveSpaceLabel(), 2.8f),
441+
KeySpec.Special(SpecialKey.CursorRight, getString(R.string.ime_action_cursor_right), 0.8f),
425442
KeySpec.Text(resolveCommaLabel(), resolveCommaOutput(), 0.8f),
426-
KeySpec.Special(SpecialKey.Space, resolveSpaceLabel(), 3.6f),
427443
KeySpec.Text(resolvePeriodLabel(), resolvePeriodOutput(), 0.8f),
428444
KeySpec.Special(SpecialKey.Enter, resolveEnterLabel(), 1.2f)
429445
)
@@ -442,8 +458,10 @@ class ClipSyncInputMethodService : InputMethodService() {
442458
KeySpec.Special(SpecialKey.SystemKeyboard, getString(R.string.ime_action_switch_keyboard), 1.05f),
443459
KeySpec.Special(SpecialKey.ToggleLanguage, resolveLanguageToggleLabel(), 1f),
444460
KeySpec.Special(SpecialKey.ModeLetters, getString(R.string.ime_action_letters), 1f),
461+
KeySpec.Special(SpecialKey.CursorLeft, getString(R.string.ime_action_cursor_left), 0.8f),
462+
KeySpec.Special(SpecialKey.Space, resolveSpaceLabel(), 2.8f),
463+
KeySpec.Special(SpecialKey.CursorRight, getString(R.string.ime_action_cursor_right), 0.8f),
445464
KeySpec.Text(resolveCommaLabel(), resolveCommaOutput(), 0.8f),
446-
KeySpec.Special(SpecialKey.Space, resolveSpaceLabel(), 3.6f),
447465
KeySpec.Text(resolvePeriodLabel(), resolvePeriodOutput(), 0.8f),
448466
KeySpec.Special(SpecialKey.Enter, resolveEnterLabel(), 1.2f)
449467
)
@@ -462,8 +480,10 @@ class ClipSyncInputMethodService : InputMethodService() {
462480
KeySpec.Special(SpecialKey.SystemKeyboard, getString(R.string.ime_action_switch_keyboard), 1.05f),
463481
KeySpec.Special(SpecialKey.ToggleLanguage, resolveLanguageToggleLabel(), 1f),
464482
KeySpec.Special(SpecialKey.ModeLetters, getString(R.string.ime_action_letters), 1f),
483+
KeySpec.Special(SpecialKey.CursorLeft, getString(R.string.ime_action_cursor_left), 0.8f),
484+
KeySpec.Special(SpecialKey.Space, resolveSpaceLabel(), 2.8f),
485+
KeySpec.Special(SpecialKey.CursorRight, getString(R.string.ime_action_cursor_right), 0.8f),
465486
KeySpec.Text("-", "-", 0.8f),
466-
KeySpec.Special(SpecialKey.Space, resolveSpaceLabel(), 3.6f),
467487
KeySpec.Text("@", "@", 0.8f),
468488
KeySpec.Special(SpecialKey.Enter, resolveEnterLabel(), 1.2f)
469489
)
@@ -512,12 +532,26 @@ class ClipSyncInputMethodService : InputMethodService() {
512532
}
513533
}
514534

535+
if (key is KeySpec.Special && key.action == SpecialKey.Shift) {
536+
setOnLongClickListener {
537+
toggleCapsLock()
538+
true
539+
}
540+
}
541+
515542
if (key is KeySpec.Special && key.action == SpecialKey.SystemKeyboard) {
516543
setOnLongClickListener {
517544
showInputMethodPicker()
518545
true
519546
}
520547
}
548+
549+
if (key is KeySpec.Text && keyboardMode == KeyboardMode.Symbols) {
550+
setOnLongClickListener {
551+
handleSymbolLongPress(key)
552+
true
553+
}
554+
}
521555
}
522556
}
523557

@@ -549,7 +583,14 @@ class ClipSyncInputMethodService : InputMethodService() {
549583
when (action) {
550584
SpecialKey.Shift -> {
551585
if (!isChineseMode) {
552-
isUppercase = !isUppercase
586+
val now = System.currentTimeMillis()
587+
if (now - lastShiftTapAt <= DOUBLE_TAP_WINDOW_MS) {
588+
toggleCapsLock()
589+
} else {
590+
isCapsLockEnabled = false
591+
isUppercase = !isUppercase
592+
}
593+
lastShiftTapAt = now
553594
renderKeyboard()
554595
}
555596
}
@@ -610,6 +651,24 @@ class ClipSyncInputMethodService : InputMethodService() {
610651
renderKeyboard()
611652
}
612653

654+
SpecialKey.CandidatePrevPage -> {
655+
if (candidatePageIndex > 0) {
656+
candidatePageIndex -= 1
657+
updateComposingState()
658+
}
659+
}
660+
661+
SpecialKey.CandidateNextPage -> {
662+
if (candidatePageIndex + 1 < totalCandidatePages) {
663+
candidatePageIndex += 1
664+
updateComposingState()
665+
}
666+
}
667+
668+
SpecialKey.CursorLeft -> moveCursor(-1)
669+
670+
SpecialKey.CursorRight -> moveCursor(1)
671+
613672
SpecialKey.SystemKeyboard -> switchToSystemKeyboard()
614673
}
615674
}
@@ -681,6 +740,42 @@ class ClipSyncInputMethodService : InputMethodService() {
681740
}
682741
}
683742

743+
private fun observeSettings() {
744+
scope.launch {
745+
settingsManager.serverUrlFlow
746+
.distinctUntilChanged()
747+
.collectLatest { url ->
748+
if (url.isBlank()) return@collectLatest
749+
if (currentConnectionState is ConnectionState.Connected || currentConnectionState == ConnectionState.Connecting) {
750+
FileLogger.d(TAG, "IME observed server url change, reconnecting")
751+
webSocketClient.connect(url)
752+
}
753+
}
754+
}
755+
756+
scope.launch {
757+
settingsManager.deviceNameFlow
758+
.distinctUntilChanged()
759+
.collectLatest {
760+
if (webSocketClient.isConnected()) {
761+
FileLogger.d(TAG, "IME observed device name change, re-sending auth")
762+
sendAuth()
763+
}
764+
}
765+
}
766+
767+
scope.launch {
768+
settingsManager.encryptionEnabledFlow
769+
.distinctUntilChanged()
770+
.collectLatest {
771+
if (webSocketClient.isConnected()) {
772+
FileLogger.d(TAG, "IME observed encryption change, refreshing auth")
773+
sendAuth()
774+
}
775+
}
776+
}
777+
}
778+
684779
private fun handleWebSocketMessage(json: String) {
685780
val wsMessage = WsMessage.fromJson(json)
686781
if (wsMessage == null) {
@@ -977,6 +1072,10 @@ class ClipSyncInputMethodService : InputMethodService() {
9771072
}
9781073

9791074
private fun syncShiftStateForCursor(force: Boolean = false) {
1075+
if (isCapsLockEnabled) {
1076+
isUppercase = true
1077+
return
1078+
}
9801079
val newUppercase = shouldAutoCapitalize(currentEditorInfo)
9811080
if (force || newUppercase != isUppercase) {
9821081
isUppercase = newUppercase
@@ -1123,17 +1222,26 @@ class ClipSyncInputMethodService : InputMethodService() {
11231222

11241223
private fun appendPinyin(value: String) {
11251224
composingPinyin += value.lowercase()
1225+
candidatePageIndex = 0
11261226
updateComposingState()
11271227
}
11281228

11291229
private fun removeLastPinyinLetter() {
11301230
if (composingPinyin.isEmpty()) return
11311231
composingPinyin = composingPinyin.dropLast(1)
1232+
candidatePageIndex = 0
11321233
updateComposingState()
11331234
}
11341235

11351236
private fun updateComposingState() {
1136-
composingCandidates = PinyinCandidateEngine.getCandidates(composingPinyin, CANDIDATE_LIMIT)
1237+
val page = PinyinCandidateEngine.getCandidatePage(
1238+
pinyin = composingPinyin,
1239+
pageIndex = candidatePageIndex,
1240+
pageSize = CANDIDATE_LIMIT
1241+
)
1242+
candidatePageIndex = page.pageIndex
1243+
totalCandidatePages = page.totalPages
1244+
composingCandidates = page.items.map { it.text }
11371245
if (composingPinyin.isEmpty()) {
11381246
currentInputConnection?.finishComposingText()
11391247
} else {
@@ -1166,6 +1274,7 @@ class ClipSyncInputMethodService : InputMethodService() {
11661274
private fun renderComposingBar() {
11671275
val bar = composingBar ?: return
11681276
val textView = composingTextView ?: return
1277+
val pageView = composingPageView ?: return
11691278
val row = candidateRow ?: return
11701279

11711280
val visible = isChineseMode && keyboardMode == KeyboardMode.Letters
@@ -1177,12 +1286,23 @@ class ClipSyncInputMethodService : InputMethodService() {
11771286
} else {
11781287
getString(R.string.ime_composing_prefix, composingPinyin)
11791288
}
1289+
pageView.text = if (composingPinyin.isBlank() || totalCandidatePages <= 1) {
1290+
""
1291+
} else {
1292+
getString(R.string.ime_candidate_page, candidatePageIndex + 1, totalCandidatePages)
1293+
}
11801294

11811295
row.removeAllViews()
11821296
if (composingPinyin.isBlank()) {
11831297
return
11841298
}
11851299

1300+
if (candidatePageIndex > 0) {
1301+
row.addView(createCandidateChip(getString(R.string.ime_action_prev_page), emphasized = false) {
1302+
handleSpecialKey(SpecialKey.CandidatePrevPage)
1303+
})
1304+
}
1305+
11861306
if (composingCandidates.isEmpty()) {
11871307
row.addView(createCandidateChip(getString(R.string.ime_candidate_raw, composingPinyin), emphasized = true) {
11881308
commitComposingText(selected = composingPinyin)
@@ -1205,6 +1325,12 @@ class ClipSyncInputMethodService : InputMethodService() {
12051325
commitComposingText(selected = composingPinyin)
12061326
})
12071327

1328+
if (candidatePageIndex + 1 < totalCandidatePages) {
1329+
row.addView(createCandidateChip(getString(R.string.ime_action_next_page), emphasized = false) {
1330+
handleSpecialKey(SpecialKey.CandidateNextPage)
1331+
})
1332+
}
1333+
12081334
candidateScrollView?.post { candidateScrollView?.scrollTo(0, 0) }
12091335
}
12101336

@@ -1227,7 +1353,45 @@ class ClipSyncInputMethodService : InputMethodService() {
12271353
rightMargin = dp(6)
12281354
}
12291355
setOnClickListener { onClick() }
1356+
setOnLongClickListener {
1357+
currentInputConnection?.commitText(label.substringAfter(". ", label), 1)
1358+
true
1359+
}
1360+
}
1361+
}
1362+
1363+
private fun toggleCapsLock() {
1364+
isCapsLockEnabled = !isCapsLockEnabled
1365+
isUppercase = isCapsLockEnabled || !isUppercase
1366+
renderKeyboard()
1367+
}
1368+
1369+
private fun moveCursor(offset: Int) {
1370+
if (composingPinyin.isNotEmpty()) {
1371+
commitComposingText()
12301372
}
1373+
val extracted = currentInputConnection?.getExtractedText(
1374+
android.view.inputmethod.ExtractedTextRequest(),
1375+
0
1376+
)
1377+
val current = extracted?.selectionStart ?: 0
1378+
val target = (current + offset).coerceAtLeast(0)
1379+
currentInputConnection?.setSelection(target, target)
1380+
}
1381+
1382+
private fun handleSymbolLongPress(key: KeySpec.Text) {
1383+
val alternate = when (key.output) {
1384+
"." -> ""
1385+
"," -> ""
1386+
"?" -> ""
1387+
"!" -> ""
1388+
":" -> ""
1389+
";" -> ""
1390+
"-" -> "_"
1391+
"@" -> "#"
1392+
else -> key.output
1393+
}
1394+
currentInputConnection?.commitText(alternate, 1)
12311395
}
12321396

12331397
private fun createContainerBackground(fillColor: Int, strokeColor: Int, radiusDp: Int): GradientDrawable {
@@ -1250,7 +1414,8 @@ class ClipSyncInputMethodService : InputMethodService() {
12501414
private const val TAG = "ClipSyncIME"
12511415
private const val DELETE_REPEAT_START_DELAY_MS = 350L
12521416
private const val DELETE_REPEAT_INTERVAL_MS = 50L
1253-
private const val CANDIDATE_LIMIT = 12
1417+
private const val CANDIDATE_LIMIT = 8
1418+
private const val DOUBLE_TAP_WINDOW_MS = 280L
12541419
}
12551420
}
12561421

@@ -1275,6 +1440,10 @@ private enum class SpecialKey {
12751440
Delete,
12761441
Space,
12771442
Enter,
1443+
CandidatePrevPage,
1444+
CandidateNextPage,
1445+
CursorLeft,
1446+
CursorRight,
12781447
ModeLetters,
12791448
ModeNumbers,
12801449
ModeSymbols,

0 commit comments

Comments
 (0)