feat(clipboard): add common words and completion

This commit is contained in:
fflow2023 2026-07-30 20:50:46 +08:00
parent d6190d133e
commit f5216c5758
23 changed files with 720 additions and 21 deletions

View File

@ -0,0 +1,40 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android
import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseEntry
import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseManager
import org.junit.Assert.assertEquals
import org.junit.Test
class CommonWordsManagerTest {
@Test
fun commonWordsUseQuickPhraseStorageAndPublishUpdates() {
val original = QuickPhraseManager.loadCommonWords()
val updates = mutableListOf<List<QuickPhraseEntry>>()
val listener = QuickPhraseManager.OnCommonWordsChangedListener {
updates.add(it)
}
val address = QuickPhraseEntry("address", "北京市朝阳区示例路100号")
val account = QuickPhraseEntry("account", "Account-Example-001")
QuickPhraseManager.addOnCommonWordsChangedListener(listener)
try {
QuickPhraseManager.saveCommonWords(listOf(address, account))
assertEquals(listOf(address, account), QuickPhraseManager.loadCommonWords())
assertEquals(listOf(address, account), updates.last())
assertEquals(
listOf(account),
QuickPhraseManager.deleteCommonWord(address)
)
assertEquals(listOf(account), QuickPhraseManager.loadCommonWords())
assertEquals(listOf(account), updates.last())
} finally {
QuickPhraseManager.removeOnCommonWordsChangedListener(listener)
QuickPhraseManager.saveCommonWords(original)
}
}
}

View File

@ -0,0 +1,43 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.data.quickphrase
object CommonWordMatcher {
const val MIN_PREFIX_CODE_POINTS = 3
data class Match(
val entry: QuickPhraseEntry,
val matchedPrefix: String,
val completion: String
)
fun bestMatch(
textBeforeCursor: String,
entries: List<QuickPhraseEntry>,
minPrefixCodePoints: Int = MIN_PREFIX_CODE_POINTS
): Match? = entries
.mapNotNull { match(textBeforeCursor, it, minPrefixCodePoints) }
.maxByOrNull { it.matchedPrefix.codePointCount(0, it.matchedPrefix.length) }
private fun match(
textBeforeCursor: String,
entry: QuickPhraseEntry,
minPrefixCodePoints: Int
): Match? {
val phrase = entry.phrase
val phraseCodePoints = phrase.codePointCount(0, phrase.length)
if (phraseCodePoints <= minPrefixCodePoints) return null
val beforeCodePoints = textBeforeCursor.codePointCount(0, textBeforeCursor.length)
val maxPrefix = minOf(phraseCodePoints - 1, beforeCodePoints)
for (prefixLength in maxPrefix downTo minPrefixCodePoints) {
val prefixEnd = phrase.offsetByCodePoints(0, prefixLength)
val prefix = phrase.substring(0, prefixEnd)
if (textBeforeCursor.endsWith(prefix, ignoreCase = true)) {
return Match(entry, prefix, phrase.substring(prefixEnd))
}
}
return null
}
}

View File

@ -8,6 +8,7 @@ import org.fcitx.fcitx5.android.R
import org.fcitx.fcitx5.android.core.data.DataManager
import org.fcitx.fcitx5.android.utils.appContext
import org.fcitx.fcitx5.android.utils.errorRuntime
import org.fcitx.fcitx5.android.utils.WeakHashSet
import org.fcitx.fcitx5.android.utils.withTempDir
import java.io.File
import java.io.InputStream
@ -19,9 +20,51 @@ object QuickPhraseManager {
)
private val customQuickPhraseDir = File(
appContext.getExternalFilesDir(null)!!, "data/data/quickphrase.d"
appContext.getExternalFilesDir(null) ?: appContext.filesDir,
"data/data/quickphrase.d"
).also { it.mkdirs() }
val commonWords: CustomQuickPhrase by lazy {
val file = File(customQuickPhraseDir, "$COMMON_WORDS_FILE_NAME.${QuickPhrase.EXT}")
if (!file.exists()) file.createNewFile()
CustomQuickPhrase(file)
}
fun isCommonWords(quickPhrase: QuickPhrase): Boolean =
quickPhrase.file.absolutePath == commonWords.file.absolutePath
fun interface OnCommonWordsChangedListener {
fun onChanged(entries: List<QuickPhraseEntry>)
}
private val commonWordsListeners = WeakHashSet<OnCommonWordsChangedListener>()
fun addOnCommonWordsChangedListener(listener: OnCommonWordsChangedListener) {
commonWordsListeners.add(listener)
}
fun removeOnCommonWordsChangedListener(listener: OnCommonWordsChangedListener) {
commonWordsListeners.remove(listener)
}
@Synchronized
fun loadCommonWords(): List<QuickPhraseEntry> = commonWords.loadData().toList()
@Synchronized
fun saveCommonWords(entries: List<QuickPhraseEntry>) {
commonWords.saveData(QuickPhraseData(entries))
val snapshot = entries.toList()
commonWordsListeners.forEach { it.onChanged(snapshot) }
}
@Synchronized
fun deleteCommonWord(entry: QuickPhraseEntry): List<QuickPhraseEntry> {
val entries = loadCommonWords().toMutableList()
entries.remove(entry)
saveCommonWords(entries)
return entries
}
fun listQuickPhrase(): List<QuickPhrase> {
val builtin = listDir(builtinQuickPhraseDir) { file ->
BuiltinQuickPhrase(file, File(customQuickPhraseDir, file.name))
@ -74,5 +117,5 @@ object QuickPhraseManager {
?.let { block(file) }
} ?: listOf()
}
private const val COMMON_WORDS_FILE_NAME = "f5clipboard-common-words"
}

View File

@ -34,6 +34,9 @@ import org.fcitx.fcitx5.android.data.clipboard.ClipboardManager
import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardEntry
import org.fcitx.fcitx5.android.data.prefs.AppPrefs
import org.fcitx.fcitx5.android.data.prefs.ManagedPreference
import org.fcitx.fcitx5.android.data.quickphrase.CommonWordMatcher
import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseEntry
import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseManager
import org.fcitx.fcitx5.android.data.theme.ThemeManager
import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.State.ClickToAttachWindow
import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.State.ClickToDetachWindow
@ -109,6 +112,9 @@ class KawaiiBarComponent : UniqueViewComponent<KawaiiBarComponent, FrameLayout>(
private var clipboardTimeoutJob: Job? = null
private var isClipboardFresh: Boolean = false
private var commonWords: List<QuickPhraseEntry> = emptyList()
private var commonWordMatch: CommonWordMatcher.Match? = null
private var commonWordSuggestionsAllowed: Boolean = true
private var isInlineSuggestionPresent: Boolean = false
private var isCapabilityFlagsPassword: Boolean = false
private var isKeyboardLayoutNumber: Boolean = false
@ -118,6 +124,15 @@ class KawaiiBarComponent : UniqueViewComponent<KawaiiBarComponent, FrameLayout>(
private var numberRowState = NumberRowState.Auto
@Keep
private val onCommonWordsChangedListener =
QuickPhraseManager.OnCommonWordsChangedListener {
service.lifecycleScope.launch {
commonWords = it
refreshCommonWordSuggestion()
}
}
@Keep
private val onClipboardUpdateListener =
ClipboardManager.OnClipboardUpdateListener {
@ -176,6 +191,7 @@ class KawaiiBarComponent : UniqueViewComponent<KawaiiBarComponent, FrameLayout>(
private fun evalIdleUiState(fromUser: Boolean = false) {
val newState = when {
numberRowState == NumberRowState.ForceShow -> IdleUi.State.NumberRow
commonWordMatch != null -> IdleUi.State.CommonWord
isClipboardFresh -> IdleUi.State.Clipboard
isInlineSuggestionPresent -> IdleUi.State.InlineSuggestion
isCapabilityFlagsPassword && !isKeyboardLayoutNumber && numberRowState != NumberRowState.ForceHide -> IdleUi.State.NumberRow
@ -324,6 +340,17 @@ class KawaiiBarComponent : UniqueViewComponent<KawaiiBarComponent, FrameLayout>(
true
}
}
commonWordUi.suggestionView.apply {
setOnClickListener {
commonWordMatch?.completion?.let { service.commitText(it) }
commonWordMatch = null
evalIdleUiState()
}
setOnLongClickListener {
AppUtil.launchMainToCommonWords(context)
true
}
}
numberRow.apply {
onCollapseListener = {
numberRowState = NumberRowState.ForceHide
@ -427,14 +454,21 @@ class KawaiiBarComponent : UniqueViewComponent<KawaiiBarComponent, FrameLayout>(
}
}
ClipboardManager.addOnUpdateListener(onClipboardUpdateListener)
commonWords = QuickPhraseManager.loadCommonWords()
QuickPhraseManager.addOnCommonWordsChangedListener(onCommonWordsChangedListener)
clipboardSuggestion.registerOnChangeListener(onClipboardSuggestionUpdateListener)
clipboardItemTimeout.registerOnChangeListener(onClipboardTimeoutUpdateListener)
}
override fun onStartInput(info: EditorInfo, capFlags: CapabilityFlags) {
val privateMode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
info.imeOptions.hasFlag(EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
idleUi.privateMode(info.imeOptions.hasFlag(EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING))
idleUi.privateMode(privateMode)
}
commonWords = QuickPhraseManager.loadCommonWords()
commonWordSuggestionsAllowed = !privateMode && !capFlags.has(CapabilityFlag.Password)
commonWordMatch = null
isCapabilityFlagsPassword = toolbarNumRowOnPassword && capFlags.has(CapabilityFlag.Password)
isInlineSuggestionPresent = false
numberRowState = NumberRowState.Auto
@ -449,6 +483,31 @@ class KawaiiBarComponent : UniqueViewComponent<KawaiiBarComponent, FrameLayout>(
if (shouldShowVoiceInput) switchToVoiceInputCallback else hideKeyboardCallback
)
evalIdleUiState()
refreshCommonWordSuggestion()
}
override fun onSelectionUpdate(start: Int, end: Int) {
if (start != end) {
commonWordMatch = null
evalIdleUiState()
return
}
refreshCommonWordSuggestion()
}
private fun refreshCommonWordSuggestion() {
commonWordMatch = if (commonWordSuggestionsAllowed) {
val textBeforeCursor = runCatching {
service.currentInputConnection
?.getTextBeforeCursor(COMMON_WORD_LOOKBEHIND, 0)
?.toString()
}.getOrNull().orEmpty()
CommonWordMatcher.bestMatch(textBeforeCursor, commonWords)
} else {
null
}
commonWordMatch?.let { idleUi.commonWordUi.text.text = it.entry.phrase }
evalIdleUiState()
}
override fun onPreeditEmptyStateUpdate(empty: Boolean) {
@ -540,6 +599,7 @@ class KawaiiBarComponent : UniqueViewComponent<KawaiiBarComponent, FrameLayout>(
companion object {
const val HEIGHT = 40
private const val COMMON_WORD_LOOKBEHIND = 256
}
fun onKeyboardLayoutSwitched(isNumber: Boolean) {

View File

@ -21,6 +21,7 @@ import org.fcitx.fcitx5.android.data.theme.Theme
import org.fcitx.fcitx5.android.input.bar.KawaiiBarComponent
import org.fcitx.fcitx5.android.input.bar.ui.idle.ButtonsBarUi
import org.fcitx.fcitx5.android.input.bar.ui.idle.ClipboardSuggestionUi
import org.fcitx.fcitx5.android.input.bar.ui.idle.CommonWordSuggestionUi
import org.fcitx.fcitx5.android.input.bar.ui.idle.InlineSuggestionsUi
import org.fcitx.fcitx5.android.input.bar.ui.idle.NumberRow
import org.fcitx.fcitx5.android.input.keyboard.CommonKeyActionListener
@ -50,7 +51,7 @@ class IdleUi(
) : Ui {
enum class State {
Empty, Toolbar, Clipboard, NumberRow, InlineSuggestion
Empty, Toolbar, Clipboard, CommonWord, NumberRow, InlineSuggestion
}
var currentState = State.Empty
@ -83,6 +84,8 @@ class IdleUi(
val clipboardUi = ClipboardSuggestionUi(ctx, theme)
val commonWordUi = CommonWordSuggestionUi(ctx, theme)
val numberRow = NumberRow(ctx, theme).apply {
visibility = View.GONE
}
@ -93,6 +96,7 @@ class IdleUi(
add(emptyBar, lParams(matchParent, matchParent))
add(buttonsUi.root, lParams(matchParent, matchParent))
add(clipboardUi.root, lParams(matchParent, matchParent))
add(commonWordUi.root, lParams(matchParent, matchParent))
add(inlineSuggestionsBar.root, lParams(matchParent, matchParent))
}
@ -221,8 +225,9 @@ class IdleUi(
State.Empty -> animator.displayedChild = 0
State.Toolbar -> animator.displayedChild = 1
State.Clipboard -> animator.displayedChild = 2
State.CommonWord -> animator.displayedChild = 3
State.NumberRow -> {}
State.InlineSuggestion -> animator.displayedChild = 3
State.InlineSuggestion -> animator.displayedChild = 4
}
if (state == State.NumberRow) {
numberRow.keyActionListener = commonKeyActionListener.listener

View File

@ -0,0 +1,74 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.input.bar.ui.idle
import android.content.Context
import android.text.TextUtils
import org.fcitx.fcitx5.android.R
import org.fcitx.fcitx5.android.data.theme.Theme
import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView
import org.fcitx.fcitx5.android.utils.rippleDrawable
import splitties.dimensions.dp
import splitties.resources.drawable
import splitties.views.dsl.constraintlayout.after
import splitties.views.dsl.constraintlayout.before
import splitties.views.dsl.constraintlayout.centerInParent
import splitties.views.dsl.constraintlayout.centerVertically
import splitties.views.dsl.constraintlayout.constraintLayout
import splitties.views.dsl.constraintlayout.endOfParent
import splitties.views.dsl.constraintlayout.lParams
import splitties.views.dsl.constraintlayout.matchConstraints
import splitties.views.dsl.constraintlayout.startOfParent
import splitties.views.dsl.core.Ui
import splitties.views.dsl.core.add
import splitties.views.dsl.core.imageView
import splitties.views.dsl.core.lParams
import splitties.views.dsl.core.matchParent
import splitties.views.dsl.core.textView
import splitties.views.dsl.core.verticalMargin
import splitties.views.dsl.core.wrapContent
import splitties.views.imageDrawable
class CommonWordSuggestionUi(override val ctx: Context, theme: Theme) : Ui {
private val icon = imageView {
imageDrawable = drawable(R.drawable.ic_baseline_star_24)!!.apply {
setTint(theme.accentKeyBackgroundColor)
}
}
val text = textView {
isSingleLine = true
maxWidth = dp(260)
ellipsize = TextUtils.TruncateAt.END
setTextColor(theme.altKeyTextColor)
}
private val layout = constraintLayout {
val spacing = dp(4)
add(icon, lParams(dp(18), dp(18)) {
startOfParent(spacing)
before(text)
centerVertically()
})
add(text, lParams(wrapContent, wrapContent) {
after(icon, spacing)
endOfParent(spacing)
centerVertically()
})
}
val suggestionView = CustomGestureView(ctx).apply {
add(layout, lParams(wrapContent, matchParent))
background = rippleDrawable(theme.keyPressHighlightColor)
}
override val root = constraintLayout {
add(suggestionView, lParams(wrapContent, matchConstraints) {
centerInParent()
verticalMargin = dp(4)
})
}
}

View File

@ -109,6 +109,33 @@ sealed class ClipboardInstructionUi(override val ctx: Context, protected val the
}
}
class CommonWordsEmpty(ctx: Context, theme: Theme) : ClipboardInstructionUi(ctx, theme) {
private val icon = imageView {
imageDrawable = drawable(R.drawable.ic_outline_star_24)!!.apply {
setTint(theme.altKeyTextColor)
}
}
private val instructionText = textView {
setText(R.string.instruction_common_words)
setTextColor(theme.keyTextColor)
}
override val root = constraintLayout {
add(icon, lParams(dp(90), dp(90)) {
topOfParent(dp(24))
startOfParent()
endOfParent()
})
add(instructionText, lParams(wrapContent, wrapContent) {
below(icon, dp(16))
startOfParent()
endOfParent()
})
}
}
class FilteredEmpty(ctx: Context, theme: Theme) : ClipboardInstructionUi(ctx, theme) {
private val icon = imageView {
@ -138,6 +165,7 @@ sealed class ClipboardInstructionUi(override val ctx: Context, protected val the
val format = when (section) {
ClipboardPanelSection.Clipboard -> R.string.instruction_no_category
ClipboardPanelSection.Favorites -> R.string.instruction_no_favorite_category
ClipboardPanelSection.CommonWords -> R.string.instruction_no_category
}
instructionText.text = ctx.getString(format, ctx.getString(category.stringRes))
}

View File

@ -6,5 +6,6 @@ package org.fcitx.fcitx5.android.input.clipboard
enum class ClipboardPanelSection {
Clipboard,
Favorites
Favorites,
CommonWords
}

View File

@ -66,11 +66,17 @@ class ClipboardTopBarUi(override val ctx: Context, private val theme: Theme) : U
private val clipboardTab = Tab(ClipboardPanelSection.Clipboard, R.string.clipboard)
private val favoritesTab = Tab(ClipboardPanelSection.Favorites, R.string.favorites)
private val commonWordsTab = Tab(ClipboardPanelSection.CommonWords, R.string.common_words)
val deleteAllButton = ToolButton(ctx, R.drawable.ic_baseline_delete_sweep_24, theme).apply {
contentDescription = ctx.getString(R.string.delete_all)
}
val addCommonWordButton = ToolButton(ctx, R.drawable.ic_baseline_add_24, theme).apply {
contentDescription = ctx.getString(R.string.add_common_word)
visibility = View.INVISIBLE
}
private var onSectionSelected: ((ClipboardPanelSection) -> Unit)? = null
override val root = constraintLayout {
@ -84,12 +90,22 @@ class ClipboardTopBarUi(override val ctx: Context, private val theme: Theme) : U
topOfParent()
after(clipboardTab.root)
bottomOfParent()
before(commonWordsTab.root)
})
add(commonWordsTab.root, lParams {
topOfParent()
after(favoritesTab.root)
bottomOfParent()
before(deleteAllButton)
})
add(deleteAllButton, lParams(dp(40), dp(40)) {
centerVertically()
endOfParent()
})
add(addCommonWordButton, lParams(dp(40), dp(40)) {
centerVertically()
endOfParent()
})
}
init {
@ -103,9 +119,14 @@ class ClipboardTopBarUi(override val ctx: Context, private val theme: Theme) : U
fun setActiveSection(section: ClipboardPanelSection) {
clipboardTab.setActive(section == ClipboardPanelSection.Clipboard)
favoritesTab.setActive(section == ClipboardPanelSection.Favorites)
commonWordsTab.setActive(section == ClipboardPanelSection.CommonWords)
}
fun setDeleteButtonShown(shown: Boolean) {
deleteAllButton.visibility = if (shown) View.VISIBLE else View.INVISIBLE
}
fun setCommonWordButtonShown(shown: Boolean) {
addCommonWordButton.visibility = if (shown) View.VISIBLE else View.INVISIBLE
}
}

View File

@ -40,6 +40,12 @@ class ClipboardUi(override val ctx: Context, private val theme: Theme) : Ui {
val filteredEmptyUi = ClipboardInstructionUi.FilteredEmpty(ctx, theme)
val commonWordsRecyclerView = recyclerView {
addItemDecoration(SpacesItemDecoration(dp(4)))
}
val commonWordsEmptyUi = ClipboardInstructionUi.CommonWordsEmpty(ctx, theme)
val categoryBar = ClipboardCategoryBarUi(ctx, theme)
val viewAnimator = view(::ViewAnimator) {
@ -48,6 +54,8 @@ class ClipboardUi(override val ctx: Context, private val theme: Theme) : Ui {
add(enableUi.root, lParams(matchParent, matchParent))
add(favoritesEmptyUi.root, lParams(matchParent, matchParent))
add(filteredEmptyUi.root, lParams(matchParent, matchParent))
add(commonWordsRecyclerView, lParams(matchParent, matchParent))
add(commonWordsEmptyUi.root, lParams(matchParent, matchParent))
}
private val keyBorder by ThemeManager.prefs.keyBorder
@ -91,5 +99,13 @@ class ClipboardUi(override val ctx: Context, private val theme: Theme) : Ui {
categoryBar.root.visibility =
if (state == ClipboardStateMachine.State.EnableListening) View.GONE else View.VISIBLE
topBar.setDeleteButtonShown(showDeleteButton)
topBar.setCommonWordButtonShown(false)
}
fun showCommonWords(empty: Boolean) {
viewAnimator.displayedChild = if (empty) 6 else 5
categoryBar.root.visibility = View.GONE
topBar.setDeleteButtonShown(false)
topBar.setCommonWordButtonShown(true)
}
}

View File

@ -27,15 +27,20 @@ import com.google.android.material.snackbar.BaseTransientBottomBar.BaseCallback
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.snackbar.SnackbarContentLayout
import kotlinx.coroutines.Job
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.fcitx.fcitx5.android.R
import org.fcitx.fcitx5.android.core.reloadQuickPhrase
import org.fcitx.fcitx5.android.data.clipboard.ClipboardCategory
import org.fcitx.fcitx5.android.data.clipboard.ClipboardManager
import org.fcitx.fcitx5.android.data.clipboard.ClipboardEntryFilter
import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardEntry
import org.fcitx.fcitx5.android.data.prefs.AppPrefs
import org.fcitx.fcitx5.android.data.prefs.ManagedPreference
import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseEntry
import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseManager
import org.fcitx.fcitx5.android.data.theme.ThemeManager
import org.fcitx.fcitx5.android.input.FcitxInputMethodService
import org.fcitx.fcitx5.android.input.dependency.inputMethodService
@ -75,11 +80,23 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
private val clipboardEntryRadius by ThemeManager.prefs.clipboardEntryRadius
private var adapterSubmitJob: Job? = null
private var commonWordsLoadJob: Job? = null
private var selectedSection = ClipboardPanelSection.Clipboard
private var selectedCategory: ClipboardCategory? = null
private var visibleEntriesEmpty = true
private var deleteAvailable = false
private var commonWordsEmpty = true
@Keep
private val commonWordsChangedListener =
QuickPhraseManager.OnCommonWordsChangedListener { entries ->
service.lifecycleScope.launch {
commonWordsAdapter.updateEntries(entries)
commonWordsEmpty = entries.isEmpty()
if (selectedSection == ClipboardPanelSection.CommonWords) renderUi()
}
}
private val adapter: ClipboardAdapter by lazy {
object : ClipboardAdapter(
@ -132,12 +149,47 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
}
}
private val commonWordsAdapter: CommonWordsAdapter by lazy {
object : CommonWordsAdapter(
theme,
context.dp(clipboardEntryRadius.toFloat())
) {
override fun onPaste(entry: QuickPhraseEntry) {
service.commitText(entry.phrase)
if (clipboardReturnAfterPaste) windowManager.attachWindow(KeyboardWindow)
}
override fun onEdit() {
AppUtil.launchMainToCommonWords(context)
}
override fun onDelete(entry: QuickPhraseEntry) {
service.lifecycleScope.launch {
val entries = withContext(Dispatchers.IO) {
QuickPhraseManager.deleteCommonWord(entry)
}
updateEntries(entries)
commonWordsEmpty = entries.isEmpty()
renderUi()
service.postFcitxJob { reloadQuickPhrase() }
}
}
}
}
private val ui by lazy {
ClipboardUi(context, theme).apply {
recyclerView.apply {
layoutManager = StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL)
adapter = this@ClipboardWindow.adapter
}
commonWordsRecyclerView.apply {
layoutManager = StaggeredGridLayoutManager(
2,
StaggeredGridLayoutManager.VERTICAL
)
adapter = commonWordsAdapter
}
ItemTouchHelper(object : ItemTouchHelper.Callback() {
override fun getMovementFlags(
recyclerView: RecyclerView,
@ -174,6 +226,9 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
topBar.deleteAllButton.setOnClickListener {
promptDeleteAll()
}
topBar.addCommonWordButton.setOnClickListener {
AppUtil.launchMainToCommonWords(context)
}
}
}
@ -281,6 +336,13 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
private fun submitEntries() {
visibleEntriesEmpty = false
deleteAvailable = false
if (selectedSection == ClipboardPanelSection.CommonWords) {
adapterSubmitJob?.cancel()
adapterSubmitJob = null
renderUi()
loadCommonWords()
return
}
renderUi()
adapterSubmitJob?.cancel()
adapterSubmitJob = service.lifecycleScope.launch {
@ -288,6 +350,7 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
scope = when (selectedSection) {
ClipboardPanelSection.Clipboard -> ClipboardEntryFilter.Scope.All
ClipboardPanelSection.Favorites -> ClipboardEntryFilter.Scope.Favorites
ClipboardPanelSection.CommonWords -> ClipboardEntryFilter.Scope.All
},
category = selectedCategory
)
@ -297,8 +360,21 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
}
}
private fun loadCommonWords() {
commonWordsLoadJob?.cancel()
commonWordsLoadJob = service.lifecycleScope.launch {
val entries = withContext(Dispatchers.IO) {
QuickPhraseManager.loadCommonWords()
}
commonWordsAdapter.updateEntries(entries)
commonWordsEmpty = entries.isEmpty()
renderUi()
}
}
private fun refreshDeleteAvailability() {
val section = selectedSection
if (section == ClipboardPanelSection.CommonWords) return
service.lifecycleScope.launch {
val available = ClipboardManager.haveDeletable()
if (selectedSection == section) {
@ -309,6 +385,10 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
}
private fun renderUi() {
if (selectedSection == ClipboardPanelSection.CommonWords) {
ui.showCommonWords(commonWordsEmpty)
return
}
selectedCategory?.let { ui.filteredEmptyUi.setFilter(selectedSection, it) }
val state = ClipboardStateMachine.resolve(
clipboardEnabledPref.getValue(),
@ -334,6 +414,7 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
ui.topBar.setActiveSection(selectedSection)
ui.categoryBar.setActiveCategory(selectedCategory)
adapter.addLoadStateListener(adapterLoadStateListener)
QuickPhraseManager.addOnCommonWordsChangedListener(commonWordsChangedListener)
renderUi()
showSection(selectedSection)
clipboardEnabledPref.registerOnChangeListener(clipboardEnabledListener)
@ -341,10 +422,14 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
override fun onDetached() {
clipboardEnabledPref.unregisterOnChangeListener(clipboardEnabledListener)
QuickPhraseManager.removeOnCommonWordsChangedListener(commonWordsChangedListener)
adapter.removeLoadStateListener(adapterLoadStateListener)
adapter.onDetached()
commonWordsAdapter.onDetached()
adapterSubmitJob?.cancel()
adapterSubmitJob = null
commonWordsLoadJob?.cancel()
commonWordsLoadJob = null
promptMenu?.dismiss()
snackbarInstance?.dismiss()
}

View File

@ -0,0 +1,72 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.input.clipboard
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.RippleDrawable
import android.text.TextUtils
import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseEntry
import org.fcitx.fcitx5.android.data.theme.Theme
import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView
import org.fcitx.fcitx5.android.utils.alpha
import splitties.views.dsl.core.Ui
import splitties.views.dsl.core.add
import splitties.views.dsl.core.lParams
import splitties.views.dsl.core.matchParent
import splitties.views.dsl.core.textView
import splitties.views.dsl.core.verticalLayout
import splitties.views.dsl.core.wrapContent
import splitties.views.setPaddingDp
class CommonWordEntryUi(
override val ctx: Context,
theme: Theme,
radius: Float
) : Ui {
private val phrase = textView {
maxLines = 3
textSize = 14f
ellipsize = TextUtils.TruncateAt.END
setTextColor(theme.keyTextColor)
}
private val keyword = textView {
isSingleLine = true
textSize = 11f
ellipsize = TextUtils.TruncateAt.END
setTextColor(theme.keyTextColor.alpha(0.55f))
}
private val content = verticalLayout {
setPaddingDp(8, 4, 8, 4)
add(phrase, lParams(matchParent, wrapContent))
add(keyword, lParams(matchParent, wrapContent))
}
override val root = CustomGestureView(ctx).apply {
isClickable = true
foreground = RippleDrawable(
ColorStateList.valueOf(theme.keyPressHighlightColor),
null,
GradientDrawable().apply {
cornerRadius = radius
setColor(Color.WHITE)
}
)
background = GradientDrawable().apply {
cornerRadius = radius
setColor(theme.clipboardEntryColor)
}
add(content, lParams(matchParent, wrapContent))
}
fun setEntry(entry: QuickPhraseEntry) {
phrase.text = ClipboardAdapter.excerptText(entry.phrase)
keyword.text = entry.keyword
}
}

View File

@ -0,0 +1,82 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.input.clipboard
import android.annotation.SuppressLint
import android.os.Build
import android.view.ViewGroup
import android.widget.PopupMenu
import androidx.recyclerview.widget.RecyclerView
import org.fcitx.fcitx5.android.R
import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseEntry
import org.fcitx.fcitx5.android.data.theme.Theme
import org.fcitx.fcitx5.android.utils.DeviceUtil
import org.fcitx.fcitx5.android.utils.item
import splitties.resources.styledColor
abstract class CommonWordsAdapter(
private val theme: Theme,
private val entryRadius: Float
) : RecyclerView.Adapter<CommonWordsAdapter.ViewHolder>() {
private var entries: List<QuickPhraseEntry> = emptyList()
private var popupMenu: PopupMenu? = null
class ViewHolder(val ui: CommonWordEntryUi) : RecyclerView.ViewHolder(ui.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ViewHolder(CommonWordEntryUi(parent.context, theme, entryRadius))
override fun getItemCount() = entries.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val entry = entries[position]
holder.ui.apply {
setEntry(entry)
root.setOnClickListener { onPaste(entry) }
root.setOnLongClickListener {
val popup = PopupMenu(ctx, root)
val iconTint = ctx.styledColor(android.R.attr.colorControlNormal)
popup.menu.item(R.string.edit, R.drawable.ic_baseline_edit_24, iconTint) {
onEdit()
}
popup.menu.item(R.string.delete, R.drawable.ic_baseline_delete_24, iconTint) {
onDelete(entry)
}
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
!DeviceUtil.isSamsungOneUI &&
!DeviceUtil.isFlyme
) {
popup.setForceShowIcon(true)
}
popup.setOnDismissListener {
if (it === popupMenu) popupMenu = null
}
popupMenu?.dismiss()
popupMenu = popup
popup.show()
true
}
}
}
@SuppressLint("NotifyDataSetChanged")
fun updateEntries(newEntries: List<QuickPhraseEntry>) {
entries = newEntries
notifyDataSetChanged()
}
fun onDetached() {
popupMenu?.dismiss()
popupMenu = null
}
abstract fun onPaste(entry: QuickPhraseEntry)
abstract fun onEdit()
abstract fun onDelete(entry: QuickPhraseEntry)
}

View File

@ -14,9 +14,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.fcitx.fcitx5.android.R
import org.fcitx.fcitx5.android.core.reloadQuickPhrase
import org.fcitx.fcitx5.android.data.quickphrase.QuickPhrase
import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseData
import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseEntry
import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseManager
import org.fcitx.fcitx5.android.ui.common.BaseDynamicListUi
import org.fcitx.fcitx5.android.ui.common.OnItemChangedListener
import org.fcitx.fcitx5.android.ui.main.EditDeleteMenuProvider
@ -39,6 +41,10 @@ class QuickPhraseEditFragment : ProgressFragment(), OnItemChangedListener<QuickP
args.param.quickPhrase
}
private val isCommonWords by lazy {
QuickPhraseManager.isCommonWords(quickPhrase)
}
private lateinit var ui: BaseDynamicListUi<QuickPhraseEntry>
private val dustman = NaiveDustman<QuickPhraseEntry>()
@ -87,8 +93,13 @@ class QuickPhraseEditFragment : ProgressFragment(), OnItemChangedListener<QuickP
.setNegativeButton(android.R.string.cancel, null)
.show()
.onPositiveButtonClick onClick@{
val keyword = keywordField.str.trim()
// "keyword" cannot contain any black characters
var keyword = keywordField.str.trim()
val phrase = phraseField.str
if (keyword.isBlank() && isCommonWords && phrase.isNotEmpty()) {
val count = phrase.codePointCount(0, phrase.length).coerceAtMost(3)
keyword = phrase.substring(0, phrase.offsetByCodePoints(0, count))
}
// "keyword" cannot contain any blank characters
if (keyword.isBlank()) {
keywordField.error = getString(
R.string._cannot_be_empty,
@ -100,7 +111,6 @@ class QuickPhraseEditFragment : ProgressFragment(), OnItemChangedListener<QuickP
keywordField.error = null
}
// "phrase" may contain blank characters
val phrase = phraseField.str
if (phrase.isEmpty()) {
phraseField.error = getString(
R.string._cannot_be_empty,
@ -161,7 +171,14 @@ class QuickPhraseEditFragment : ProgressFragment(), OnItemChangedListener<QuickP
resetDustman()
lifecycleScope.launch {
withContext(Dispatchers.IO) {
quickPhrase.saveData(QuickPhraseData(ui.entries))
if (isCommonWords) {
QuickPhraseManager.saveCommonWords(ui.entries)
} else {
quickPhrase.saveData(QuickPhraseData(ui.entries))
}
}
if (isCommonWords) {
viewModel.fcitx.runIfReady { reloadQuickPhrase() }
}
// tell parent that we need to reload
parentFragmentManager.setFragmentResult(
@ -177,7 +194,9 @@ class QuickPhraseEditFragment : ProgressFragment(), OnItemChangedListener<QuickP
override fun onStart() {
super.onStart()
viewModel.setToolbarTitle(quickPhrase.name)
viewModel.setToolbarTitle(
if (isCommonWords) getString(R.string.common_words) else quickPhrase.name
)
}
override fun onStop() {
@ -199,4 +218,4 @@ class QuickPhraseEditFragment : ProgressFragment(), OnItemChangedListener<QuickP
const val RESULT = "dirty"
}
}
}

View File

@ -12,6 +12,7 @@ import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import org.fcitx.fcitx5.android.R
import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseManager
import org.fcitx.fcitx5.android.ui.main.ClipboardEditActivity
import org.fcitx.fcitx5.android.ui.main.MainActivity
import org.fcitx.fcitx5.android.ui.main.settings.SettingsRoute
@ -42,6 +43,9 @@ object AppUtil {
fun launchMainToThemeList(context: Context) =
launchMainToDest(context, SettingsRoute.Theme)
fun launchMainToCommonWords(context: Context) =
launchMainToDest(context, SettingsRoute.QuickPhraseEdit(QuickPhraseManager.commonWords))
fun launchMainToInputMethodConfig(context: Context, uniqueName: String, displayName: String) =
launchMainToDest(context, SettingsRoute.InputMethodConfig(displayName, uniqueName))

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z" />
</vector>

View File

@ -62,6 +62,8 @@
<string name="favorite">收藏</string>
<string name="unfavorite">取消收藏</string>
<string name="favorites">收藏</string>
<string name="common_words">常用词</string>
<string name="add_common_word">添加常用词</string>
<string name="delete">删除</string>
<string name="clipboard">剪贴板</string>
<string name="keyboard_height">键盘高度</string>
@ -84,6 +86,7 @@
<string name="instruction_enable_clipboard_listening">要启用剪贴板管理,请在设置中开启“记录剪贴板历史”。</string>
<string name="instruction_copy">试着复制些东西</string>
<string name="instruction_favorite">长按剪贴板条目即可收藏</string>
<string name="instruction_common_words">点击 + 添加常用文本</string>
<string name="instruction_no_category">没有%1$s条目</string>
<string name="instruction_no_favorite_category">没有已收藏的%1$s条目</string>
<string name="clipboard_category_all">全部</string>

View File

@ -62,6 +62,8 @@
<string name="favorite">收藏</string>
<string name="unfavorite">取消收藏</string>
<string name="favorites">收藏</string>
<string name="common_words">常用詞</string>
<string name="add_common_word">新增常用詞</string>
<string name="delete">刪除</string>
<string name="clipboard">剪貼簿</string>
<string name="keyboard_height">鍵盤高度</string>
@ -84,6 +86,7 @@
<string name="instruction_enable_clipboard_listening">要使用剪貼簿管理,請在設定中啟用監聽剪貼簿</string>
<string name="instruction_copy">嘗試複製一些東西</string>
<string name="instruction_favorite">長按剪貼簿項目即可收藏</string>
<string name="instruction_common_words">點選 + 新增常用文字</string>
<string name="instruction_no_category">沒有%1$s項目</string>
<string name="instruction_no_favorite_category">沒有已收藏的%1$s項目</string>
<string name="clipboard_category_all">全部</string>

View File

@ -62,6 +62,8 @@
<string name="favorite" tools:ignore="MissingTranslation">Add to favorites</string>
<string name="unfavorite" tools:ignore="MissingTranslation">Remove from favorites</string>
<string name="favorites" tools:ignore="MissingTranslation">Favorites</string>
<string name="common_words" tools:ignore="MissingTranslation">Common words</string>
<string name="add_common_word" tools:ignore="MissingTranslation">Add common word</string>
<string name="delete">Delete</string>
<string name="clipboard">Clipboard</string>
<string name="keyboard_height">Keyboard height</string>
@ -84,6 +86,7 @@
<string name="instruction_enable_clipboard_listening">To use clipboard management, please enable clipboard listening in settings</string>
<string name="instruction_copy">Try copying something</string>
<string name="instruction_favorite" tools:ignore="MissingTranslation">Long press a clipboard item to add it to favorites</string>
<string name="instruction_common_words" tools:ignore="MissingTranslation">Tap + to add frequently used text</string>
<string name="instruction_no_category" tools:ignore="MissingTranslation">No %1$s items</string>
<string name="instruction_no_favorite_category" tools:ignore="MissingTranslation">No favorite %1$s items</string>
<string name="clipboard_category_all" tools:ignore="MissingTranslation">All</string>

View File

@ -10,10 +10,14 @@ import org.junit.Assert.assertEquals
import org.junit.Test
class ClipboardStateMachineTest {
private val clipboardSections = listOf(
ClipboardPanelSection.Clipboard,
ClipboardPanelSection.Favorites
)
@Test
fun listeningDisabledAlwaysShowsEnableInstruction() {
ClipboardPanelSection.entries.forEach { section ->
clipboardSections.forEach { section ->
listOf(true, false).forEach { empty ->
assertEquals(
ClipboardStateMachine.State.EnableListening,
@ -25,7 +29,7 @@ class ClipboardStateMachineTest {
@Test
fun nonEmptySectionShowsEntries() {
ClipboardPanelSection.entries.forEach { section ->
clipboardSections.forEach { section ->
assertEquals(
ClipboardStateMachine.State.Normal,
ClipboardStateMachine.resolve(true, section, false, false)
@ -47,7 +51,7 @@ class ClipboardStateMachineTest {
@Test
fun emptyCategoryShowsFilteredInstructionInBothSections() {
ClipboardPanelSection.entries.forEach { section ->
clipboardSections.forEach { section ->
assertEquals(
ClipboardStateMachine.State.NoFilteredEntries,
ClipboardStateMachine.resolve(true, section, true, true)

View File

@ -0,0 +1,56 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android
import org.fcitx.fcitx5.android.data.quickphrase.CommonWordMatcher
import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseEntry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class CommonWordMatcherTest {
private val address = QuickPhraseEntry(
"address",
"北京市朝阳区示例路100号"
)
@Test
fun completesPhraseAfterThreeOrMoreMatchingCharacters() {
val three = CommonWordMatcher.bestMatch("收件地址:北京市", listOf(address))!!
assertEquals("北京市", three.matchedPrefix)
assertEquals("朝阳区示例路100号", three.completion)
val longer = CommonWordMatcher.bestMatch("北京市朝阳区", listOf(address))!!
assertEquals("北京市朝阳区", longer.matchedPrefix)
assertEquals("示例路100号", longer.completion)
}
@Test
fun doesNotSuggestBeforeThreeCharactersOrAfterFullPhrase() {
assertNull(CommonWordMatcher.bestMatch("北京", listOf(address)))
assertNull(CommonWordMatcher.bestMatch(address.phrase, listOf(address)))
assertNull(CommonWordMatcher.bestMatch("这是无关内容", listOf(address)))
}
@Test
fun choosesLongestCurrentPrefixAndSupportsSupplementaryCharacters() {
val short = QuickPhraseEntry("short", "北京市海淀区")
val emoji = QuickPhraseEntry("emoji", "😀😁😂常用表情")
assertEquals(
address,
CommonWordMatcher.bestMatch("北京市朝阳", listOf(short, address))?.entry
)
val match = CommonWordMatcher.bestMatch("😀😁😂", listOf(emoji))!!
assertEquals("常用表情", match.completion)
}
@Test
fun asciiMatchingIsCaseInsensitiveButPreservesStoredCompletion() {
val account = QuickPhraseEntry("account", "Account-Example-001")
val match = CommonWordMatcher.bestMatch("account", listOf(account))!!
assertEquals("-Example-001", match.completion)
}
}

View File

@ -6,7 +6,7 @@
- **阶段 1顶栏与收藏**(功能完成;全仓既有门禁问题待处理)
- **阶段 2智能管理**(分类、底部筛选和可选自动清理均已完成)
- **阶段 3常用词**待办:自定义短语、三字符匹配与一键补全)
- **阶段 3常用词**已完成:自定义短语、三字符匹配与一键补全)
上游相关需求:[fcitx5-android/fcitx5-android#456](https://github.com/fcitx5-android/fcitx5-android/issues/456)。
@ -16,7 +16,7 @@
- Room 数据库由 v4 升到 v5为剪贴板条目增加 `favorite` 状态,并通过自动迁移保留旧数据。
- 使用 `ClipboardEntryFilter` 统一“全部”和“收藏”查询入口,为后续分类过滤保留扩展点。
- 使用独立的 `ClipboardPanelSection` 表示键盘面板顶栏页面;阶段 3 再扩展“常用词”
- 使用独立的 `ClipboardPanelSection` 表示键盘面板顶栏页面,并扩展“常用词”页面
- 阶段 1 不增加 `category``expiresAt` 等尚未使用的字段,也不修改插件 AIDL。
### 收藏与删除语义
@ -116,9 +116,13 @@
### 阶段 3常用词
- 顶栏扩展为“剪贴板 / 收藏 / 常用词”。
- 复用现有 `QuickPhraseManager``QuickPhraseEntry` 和快速输入文件格式。
- 提供键盘内新增、编辑、删除和直接上屏。
- 三字符预测接入现有 preedit/candidate 广播链路;开始阶段 3 前确认匹配和候选优先级。
- 复用现有 `QuickPhraseManager``QuickPhraseEntry``.mb` 快速输入文件格式,以独立的 `f5clipboard-common-words.mb` 保存常用词,不增加数据库表。
- 常用词页支持点击直接上屏、长按删除;新增和编辑按钮从键盘打开专用快速输入编辑页,保存后立即刷新键盘数据和 Fcitx 快速输入。
- 编辑常用词时若关键词留空,自动取短语最前面的三个 Unicode 字符作为关键词;关键词只用于兼容快速输入文件格式,补全匹配始终以短语正文为准。
- 光标前已提交文本与某条常用词的开头连续匹配至少三个 Unicode 字符时,在空闲工具栏显示最佳匹配;点击只提交尚未输入的后缀,避免重复文本。
- 多条短语同时匹配时优先选择当前匹配前缀最长的条目ASCII 匹配不区分大小写,但补全文本保留已保存短语的大小写。
- 密码输入框和 `IME_FLAG_NO_PERSONALIZED_LEARNING` 私密输入模式中禁用常用词预测。
- 中文拼音仍由现有 Fcitx preedit/candidate 流程处理;常用词预测只检查编辑器中已经提交到光标前的文本,不抢占输入法候选。
## 阶段 1 验证清单
@ -182,6 +186,26 @@
- 管理器集成测试验证了复制 6 位验证码后生成有效期、收藏后取消有效期、再次复制后重新计时,以及关闭开关后清空有效期且后续复制不再安排删除。
- 模拟器设置页已检查:两个开关默认关闭,时长项仅在对应开关开启时可编辑,默认值分别为 10 分钟和 24 小时。
## 阶段 3 验证清单
- [x] 常用词使用独立 `.mb` 文件保存,可被现有快速输入管理器读取和重新加载。
- [x] 顶栏包含“常用词”页面,分类底栏在该页面隐藏,空列表显示独立提示。
- [x] 点击条目直接上屏,长按可进入编辑或删除,右侧加号可新增。
- [x] 三个及以上 Unicode 字符的连续前缀可触发预测,点击只提交缺失后缀。
- [x] 多条匹配选择最长前缀ASCII 匹配不区分大小写。
- [x] 密码输入框和私密输入模式不显示常用词预测。
- [x] `CommonWordMatcherTest` 的 4 项单元测试通过。
- [x] `testDebugUnitTest``assembleDebug``assembleDebugAndroidTest` 通过。
- [x] API 35 上常用词管理器及阶段 1、2 回归仪器测试共 14 项通过。
- [x] `git diff --check` 通过。
### 2026-07-30 阶段 3 验收记录
- 常用词管理器仪器测试验证了保存、重新读取、变更通知、删除,以及测试结束后恢复原文件。
- 模拟器中导入地址、账号、电话和邮箱样例后,输入 `Acc` 可见 `Account-Example-001` 的星标补全建议。
- 新增和编辑复用应用内的快速输入编辑页;从键盘发起后会暂时离开输入界面,保存时立即通知键盘并重新加载 Fcitx 数据。
- 预测只读取光标前已提交文本,不处理仍在拼音组合区中的 preedit 文本,以避免和现有中文候选竞争。
## 环境约定
- 使用 Android Platform 36、Build Tools 36.1.0、NDK 28.0.13004108、CMake 3.31.6。

View File

@ -0,0 +1,4 @@
address 北京市朝阳区示例路100号
account Account-Example-001
phone 13800138000
email person@example.com