feat(clipboard): add favorites panel

This commit is contained in:
fflow2023 2026-07-30 13:51:30 +08:00
parent bcb694384d
commit 50960b4ec8
31 changed files with 807 additions and 152 deletions

View File

@ -56,6 +56,8 @@ android {
@Suppress("UnstableApiUsage")
generateLocaleConfig = true
}
sourceSets.getByName("androidTest").assets.directories.add("$projectDir/schemas")
}
fcitxComponent {
@ -127,6 +129,7 @@ dependencies {
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(libs.androidx.lifecycle.testing)
androidTestImplementation(libs.junit)
}
@ -140,3 +143,9 @@ configurations {
exclude(group = "com.louiscad.splitties", module = "splitties-systemservices")
}
}
// descriptor.json is generated inside the main assets directory. Gradle 9 requires
// consumers of that directory to declare the producing task explicitly.
tasks.matching { it.name.startsWith("lint") || it.name.endsWith("LintReportModel") }.configureEach {
dependsOn("generateDataDescriptor")
}

View File

@ -0,0 +1,78 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "01175120dc4178de02503037e673b3ce",
"entities": [
{
"tableName": "clipboard",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL DEFAULT -1, `type` TEXT NOT NULL DEFAULT 'text/plain', `deleted` INTEGER NOT NULL DEFAULT 0, `sensitive` INTEGER NOT NULL DEFAULT 0, `favorite` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "-1"
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'text/plain'"
},
{
"fieldPath": "deleted",
"columnName": "deleted",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "favorite",
"columnName": "favorite",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '01175120dc4178de02503037e673b3ce')"
]
}
}

View File

@ -0,0 +1,160 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android
import androidx.paging.PagingSource
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardDao
import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardDatabase
import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardEntry
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class ClipboardDatabaseTest {
@get:Rule
val migrationHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
ClipboardDatabase::class.java
)
private lateinit var database: ClipboardDatabase
private lateinit var dao: ClipboardDao
@Before
fun setup() {
database = Room.inMemoryDatabaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
ClipboardDatabase::class.java
).build()
dao = database.clipboardDao()
}
@After
fun cleanup() {
database.close()
}
@Test
fun migration4To5PreservesEntriesAndDefaultsFavoriteToFalse() {
val name = "clipboard-migration-test"
migrationHelper.createDatabase(name, 4).apply {
execSQL(
"INSERT INTO clipboard (id, text, pinned, timestamp, type, deleted, sensitive) " +
"VALUES (1, 'kept', 1, 123, 'text/plain', 0, 1)"
)
close()
}
migrationHelper.runMigrationsAndValidate(name, 5, true).apply {
query("SELECT * FROM clipboard WHERE id=1").use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("kept", cursor.getString(cursor.getColumnIndexOrThrow("text")))
assertEquals(1, cursor.getInt(cursor.getColumnIndexOrThrow("pinned")))
assertEquals(0, cursor.getInt(cursor.getColumnIndexOrThrow("favorite")))
}
close()
}
}
@Test
fun favoritesAreFilteredAndKeepPinnedThenTimestampOrdering() = runBlocking {
insert(text = "old favorite", timestamp = 10, favorite = true)
insert(text = "new favorite", timestamp = 30, favorite = true)
insert(text = "pinned favorite", timestamp = 20, pinned = true, favorite = true)
insert(text = "not favorite", timestamp = 40)
val result = dao.favoriteEntries().load(
PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)
)
assertTrue(result is PagingSource.LoadResult.Page)
val entries = result as PagingSource.LoadResult.Page<Int, ClipboardEntry>
assertEquals(
listOf("pinned favorite", "new favorite", "old favorite"),
entries.data.map { it.text }
)
}
@Test
fun automaticDeletionTargetsOnlyUnprotectedEntries() = runBlocking {
val deletableId = insert(text = "deletable", timestamp = 10)
insert(text = "pinned", timestamp = 20, pinned = true)
insert(text = "favorite", timestamp = 30, favorite = true)
insert(text = "both", timestamp = 40, pinned = true, favorite = true)
assertTrue(dao.haveDeletable())
assertArrayEquals(intArrayOf(deletableId), dao.findDeletableIds())
dao.markUnprotectedAsDeletedEarlierThan(Long.MAX_VALUE)
assertNull(dao.get(deletableId))
assertEquals(3, dao.itemCount())
assertFalse(dao.haveDeletable())
}
@Test
fun favoriteCanStillBeDeletedAndRestoredExplicitly() = runBlocking {
val id = insert(text = "favorite", timestamp = 10, favorite = true)
dao.markAsDeleted(id)
assertNull(dao.get(id))
dao.undoDelete(id)
assertTrue(dao.get(id)?.favorite == true)
}
@Test
fun favoriteStatusCanBeToggled() = runBlocking {
val id = insert(text = "entry", timestamp = 10)
dao.updateFavoriteStatus(id, true)
assertTrue(dao.get(id)?.favorite == true)
dao.updateFavoriteStatus(id, false)
assertFalse(dao.get(id)?.favorite == true)
}
@Test
fun updatingTimestampPreservesPinnedAndFavoriteState() = runBlocking {
val id = insert(
text = "duplicate",
timestamp = 1,
pinned = true,
favorite = true
)
dao.updateTime(id, 99)
val updated = dao.get(id)!!
assertEquals(99, updated.timestamp)
assertTrue(updated.pinned)
assertTrue(updated.favorite)
}
private suspend fun insert(
text: String,
timestamp: Long,
pinned: Boolean = false,
favorite: Boolean = false
): Int = dao.insert(
ClipboardEntry(
text = text,
pinned = pinned,
timestamp = timestamp,
favorite = favorite
)
).toInt()
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<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="#6750A4"
android:pathData="M4,4h16v16h-16z" />
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<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="#6750A4"
android:pathData="M12,2a10,10 0,1 0,0 20a10,10 0,1 0,0 -20" />
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Debug build type aliases are also generated for the instrumentation APK. -->
<string name="app_name_debug" translatable="false">Fcitx5 (Debug Test)</string>
</resources>

View File

@ -0,0 +1,10 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.data.clipboard
enum class ClipboardEntryFilter {
All,
Favorites
}

View File

@ -100,14 +100,21 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
suspend fun get(id: Int) = clbDao.get(id)
suspend fun haveUnpinned() = clbDao.haveUnpinned()
suspend fun haveDeletable() = clbDao.haveDeletable()
fun allEntries() = clbDao.allEntries()
fun entries(filter: ClipboardEntryFilter) = when (filter) {
ClipboardEntryFilter.All -> clbDao.allEntries()
ClipboardEntryFilter.Favorites -> clbDao.favoriteEntries()
}
suspend fun pin(id: Int) = clbDao.updatePinStatus(id, true)
suspend fun unpin(id: Int) = clbDao.updatePinStatus(id, false)
suspend fun favorite(id: Int) = clbDao.updateFavoriteStatus(id, true)
suspend fun unfavorite(id: Int) = clbDao.updateFavoriteStatus(id, false)
suspend fun updateText(id: Int, text: String) {
lastEntry?.let {
if (id == it.id) updateLastEntry(it.copy(text = text))
@ -120,12 +127,8 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
updateItemCount()
}
suspend fun deleteAll(skipPinned: Boolean = true): IntArray {
val ids = if (skipPinned) {
clbDao.findUnpinnedIds()
} else {
clbDao.findAllIds()
}
suspend fun deleteAll(): IntArray {
val ids = clbDao.findDeletableIds()
clbDao.markAsDeleted(*ids)
updateItemCount()
return ids
@ -195,15 +198,15 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
private suspend fun removeOutdated() {
val limit = limitPref.getValue()
val unpinned = clbDao.getAllUnpinned()
if (unpinned.size > limit) {
val unprotected = clbDao.getAllUnprotected()
if (unprotected.size > limit) {
// the last one we will keep
val last = unpinned
.sortedBy { it.id }
.getOrNull(unpinned.size - limit)
// delete all unpinned before that, or delete all when limit <= 0
clbDao.markUnpinnedAsDeletedEarlierThan(last?.timestamp ?: System.currentTimeMillis())
val last = unprotected
.sortedBy { it.timestamp }
.getOrNull(unprotected.size - limit)
// delete all unprotected before that, or delete all when limit <= 0
clbDao.markUnprotectedAsDeletedEarlierThan(last?.timestamp ?: System.currentTimeMillis())
}
}
}
}

View File

@ -17,6 +17,9 @@ interface ClipboardDao {
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET pinned=:pinned WHERE id=:id")
suspend fun updatePinStatus(id: Int, pinned: Boolean)
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET favorite=:favorite WHERE id=:id")
suspend fun updateFavoriteStatus(id: Int, favorite: Boolean)
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET text=:text WHERE id=:id")
suspend fun updateText(id: Int, text: String)
@ -32,33 +35,33 @@ interface ClipboardDao {
@Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE rowId=:rowId AND deleted=0 LIMIT 1")
suspend fun get(rowId: Long): ClipboardEntry?
@Query("SELECT EXISTS(SELECT 1 FROM ${ClipboardEntry.TABLE_NAME} WHERE pinned=0 AND deleted=0)")
suspend fun haveUnpinned(): Boolean
@Query("SELECT EXISTS(SELECT 1 FROM ${ClipboardEntry.TABLE_NAME} WHERE pinned=0 AND favorite=0 AND deleted=0)")
suspend fun haveDeletable(): Boolean
@Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE pinned=0 AND deleted=0")
suspend fun getAllUnpinned(): List<ClipboardEntry>
@Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE pinned=0 AND favorite=0 AND deleted=0")
suspend fun getAllUnprotected(): List<ClipboardEntry>
@Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE deleted=0 ORDER BY pinned DESC, timestamp DESC")
fun allEntries(): PagingSource<Int, ClipboardEntry>
@Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE favorite=1 AND deleted=0 ORDER BY pinned DESC, timestamp DESC")
fun favoriteEntries(): PagingSource<Int, ClipboardEntry>
@Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE text=:text AND sensitive=:sensitive AND deleted=0 LIMIT 1")
suspend fun find(text: String, sensitive: Boolean = false): ClipboardEntry?
@Query("SELECT id FROM ${ClipboardEntry.TABLE_NAME} WHERE deleted=0")
suspend fun findAllIds(): IntArray
@Query("SELECT id FROM ${ClipboardEntry.TABLE_NAME} WHERE pinned=0 AND deleted=0")
suspend fun findUnpinnedIds(): IntArray
@Query("SELECT id FROM ${ClipboardEntry.TABLE_NAME} WHERE pinned=0 AND favorite=0 AND deleted=0")
suspend fun findDeletableIds(): IntArray
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET deleted=1 WHERE id in (:ids)")
suspend fun markAsDeleted(vararg ids: Int)
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET DELETED=1 WHERE timestamp<:timestamp AND pinned=0 AND deleted=0")
suspend fun markUnpinnedAsDeletedEarlierThan(timestamp: Long)
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET deleted=1 WHERE timestamp<:timestamp AND pinned=0 AND favorite=0 AND deleted=0")
suspend fun markUnprotectedAsDeletedEarlierThan(timestamp: Long)
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET deleted=0 WHERE id in (:ids) AND deleted=1")
suspend fun undoDelete(vararg ids: Int)
@Query("DELETE FROM ${ClipboardEntry.TABLE_NAME} WHERE deleted=1")
suspend fun realDelete()
}
}

View File

@ -10,13 +10,14 @@ import androidx.room.RoomDatabase
@Database(
entities = [ClipboardEntry::class],
version = 4,
version = 5,
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3),
AutoMigration(from = 3, to = 4)
AutoMigration(from = 3, to = 4),
AutoMigration(from = 4, to = 5)
]
)
abstract class ClipboardDatabase : RoomDatabase() {
abstract fun clipboardDao(): ClipboardDao
}
}

View File

@ -25,7 +25,9 @@ data class ClipboardEntry(
@ColumnInfo(defaultValue = "0")
val deleted: Boolean = false,
@ColumnInfo(defaultValue = "0")
val sensitive: Boolean = false
val sensitive: Boolean = false,
@ColumnInfo(defaultValue = "0")
val favorite: Boolean = false
) {
companion object {
const val BULLET = ""
@ -59,4 +61,4 @@ data class ClipboardEntry(
)
}
}
}
}

View File

@ -463,7 +463,9 @@ class KawaiiBarComponent : UniqueViewComponent<KawaiiBarComponent, FrameLayout>(
when (window) {
is InputWindow.ExtendedInputWindow<*> -> {
titleUi.setTitle(window.title)
window.onCreateBarExtension()?.let { titleUi.addExtension(it, window.showTitle) }
window.onCreateBarExtension()?.let {
titleUi.addExtension(it, window.showTitle, window.showReturnButton)
}
titleUi.setReturnButtonOnClickListener {
windowManager.attachWindow(KeyboardWindow)
}

View File

@ -65,11 +65,11 @@ class TitleUi(override val ctx: Context, theme: Theme) : Ui {
titleText.text = title
}
fun addExtension(view: View, showTitle: Boolean) {
fun addExtension(view: View, showTitle: Boolean, showReturnButton: Boolean = showTitle) {
if (extension != null) {
throw IllegalStateException("TitleBar extension is already present")
}
backButton.isVisible = showTitle
backButton.isVisible = showReturnButton
titleText.isVisible = showTitle
extension = view
root.run {
@ -77,6 +77,9 @@ class TitleUi(override val ctx: Context, theme: Theme) : Ui {
centerVertically()
if (showTitle) {
endOfParent(dp(5))
} else if (showReturnButton) {
after(backButton)
endOfParent(dp(5))
} else {
centerHorizontally()
}

View File

@ -91,7 +91,11 @@ abstract class ClipboardAdapter(
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val entry = getItem(position) ?: return
with(holder.entryUi) {
setEntry(excerptText(entry.text, entry.sensitive && maskSensitive), entry.pinned)
setEntry(
excerptText(entry.text, entry.sensitive && maskSensitive),
entry.pinned,
entry.favorite
)
root.setOnClickListener {
onPaste(entry)
}
@ -108,6 +112,15 @@ abstract class ClipboardAdapter(
onPin(entry.id)
}
}
if (entry.favorite) {
menu.item(R.string.unfavorite, R.drawable.ic_outline_star_24, iconTint) {
onUnfavorite(entry.id)
}
} else {
menu.item(R.string.favorite, R.drawable.ic_baseline_star_24, iconTint) {
onFavorite(entry.id)
}
}
menu.item(R.string.edit, R.drawable.ic_baseline_edit_24, iconTint) {
onEdit(entry.id)
}
@ -144,10 +157,14 @@ abstract class ClipboardAdapter(
abstract fun onUnpin(id: Int)
abstract fun onFavorite(id: Int)
abstract fun onUnfavorite(id: Int)
abstract fun onEdit(id: Int)
abstract fun onShare(entry: ClipboardEntry)
abstract fun onDelete(id: Int)
}
}

View File

@ -17,6 +17,7 @@ import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView
import splitties.dimensions.dp
import splitties.resources.drawable
import splitties.views.dsl.constraintlayout.bottomOfParent
import splitties.views.dsl.constraintlayout.before
import splitties.views.dsl.constraintlayout.centerVertically
import splitties.views.dsl.constraintlayout.constraintLayout
import splitties.views.dsl.constraintlayout.endOfParent
@ -49,11 +50,17 @@ class ClipboardEntryUi(override val ctx: Context, private val theme: Theme, radi
}
}
val favorite = imageView()
val layout = constraintLayout {
add(textView, lParams(matchParent, wrapContent) {
centerVertically()
})
add(pin, lParams(dp(12), dp(12)) {
bottomOfParent(dp(2))
before(favorite, dp(2))
})
add(favorite, lParams(dp(12), dp(12)) {
bottomOfParent(dp(2))
endOfParent(dp(2))
})
@ -76,8 +83,14 @@ class ClipboardEntryUi(override val ctx: Context, private val theme: Theme, radi
add(layout, lParams(matchParent, matchParent))
}
fun setEntry(text: String, pinned: Boolean) {
fun setEntry(text: String, pinned: Boolean, isFavorite: Boolean) {
textView.text = text
pin.visibility = if (pinned) View.VISIBLE else View.GONE
favorite.imageDrawable = ctx.drawable(
if (isFavorite) R.drawable.ic_baseline_star_24 else R.drawable.ic_outline_star_24
)!!.apply {
setTint(if (isFavorite) theme.accentKeyBackgroundColor else theme.altKeyTextColor)
alpha = if (isFavorite) 0xff else 0x4d
}
}
}
}

View File

@ -80,4 +80,31 @@ sealed class ClipboardInstructionUi(override val ctx: Context, protected val the
})
}
}
class FavoritesEmpty(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_favorite)
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()
})
}
}
}

View File

@ -0,0 +1,10 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.input.clipboard
enum class ClipboardPanelSection {
Clipboard,
Favorites
}

View File

@ -1,56 +1,26 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors
* SPDX-FileCopyrightText: Copyright 2021-2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.input.clipboard
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.BooleanKey.ClipboardDbEmpty
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.BooleanKey.ClipboardListeningEnabled
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.State.AddMore
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.State.EnableListening
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.State.Normal
import org.fcitx.fcitx5.android.utils.BuildTransitionEvent
import org.fcitx.fcitx5.android.utils.EventStateMachine
import org.fcitx.fcitx5.android.utils.TransitionBuildBlock
object ClipboardStateMachine {
enum class State {
Normal, AddMore, EnableListening
Normal,
AddMore,
NoFavorites,
EnableListening
}
enum class BooleanKey : EventStateMachine.BooleanStateKey {
ClipboardDbEmpty,
ClipboardListeningEnabled
fun resolve(
listening: Boolean,
section: ClipboardPanelSection,
visibleEntriesEmpty: Boolean
): State = when {
!listening -> State.EnableListening
!visibleEntriesEmpty -> State.Normal
section == ClipboardPanelSection.Favorites -> State.NoFavorites
else -> State.AddMore
}
enum class TransitionEvent(val builder: TransitionBuildBlock<State, BooleanKey>) :
EventStateMachine.TransitionEvent<State, BooleanKey> by BuildTransitionEvent(builder) {
ClipboardDbUpdated({
from(Normal) transitTo AddMore on (ClipboardDbEmpty to true)
from(AddMore) transitTo Normal on (ClipboardDbEmpty to false)
}),
ClipboardListeningUpdated({
from(Normal) transitTo EnableListening on (ClipboardListeningEnabled to false)
from(EnableListening) transitTo Normal onF {
it(ClipboardListeningEnabled) == true && it(ClipboardDbEmpty) == false
}
from(EnableListening) transitTo AddMore onF {
it(ClipboardListeningEnabled) == true && it(ClipboardDbEmpty) == true
}
from(AddMore) transitTo EnableListening on (ClipboardListeningEnabled to false)
})
}
fun new(initial: State, empty: Boolean, listening: Boolean, block: (State) -> Unit) =
EventStateMachine<State, TransitionEvent, BooleanKey>(
initialState = initial,
externalBooleanStates = mutableMapOf(
ClipboardDbEmpty to empty,
ClipboardListeningEnabled to listening
)
).apply {
onNewStateListener = block
}
}
}

View File

@ -0,0 +1,111 @@
/*
* 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.graphics.Typeface
import android.view.View
import org.fcitx.fcitx5.android.R
import org.fcitx.fcitx5.android.data.theme.Theme
import org.fcitx.fcitx5.android.data.theme.ThemeManager
import org.fcitx.fcitx5.android.input.bar.ui.ToolButton
import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView
import org.fcitx.fcitx5.android.utils.alpha
import org.fcitx.fcitx5.android.utils.pressHighlightDrawable
import org.fcitx.fcitx5.android.utils.rippleDrawable
import splitties.dimensions.dp
import splitties.views.dsl.constraintlayout.after
import splitties.views.dsl.constraintlayout.before
import splitties.views.dsl.constraintlayout.bottomOfParent
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.startOfParent
import splitties.views.dsl.constraintlayout.topOfParent
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.view
import splitties.views.gravityCenter
class ClipboardTopBarUi(override val ctx: Context, private val theme: Theme) : Ui {
private val keyRipple by ThemeManager.prefs.keyRippleEffect
inner class Tab(private val section: ClipboardPanelSection, textRes: Int) : Ui {
override val ctx = this@ClipboardTopBarUi.ctx
private val label = textView {
setText(textRes)
textSize = 15f
typeface = Typeface.DEFAULT_BOLD
gravity = gravityCenter
}
override val root = view(::CustomGestureView) {
isClickable = true
contentDescription = label.text
add(label, lParams(matchParent, matchParent))
if (keyRipple) {
background = rippleDrawable(theme.keyPressHighlightColor)
} else {
foreground = pressHighlightDrawable(theme.keyPressHighlightColor)
}
setOnClickListener { onSectionSelected?.invoke(section) }
}
fun setActive(active: Boolean) {
label.setTextColor(theme.keyTextColor.alpha(if (active) 1f else 0.5f))
}
}
private val clipboardTab = Tab(ClipboardPanelSection.Clipboard, R.string.clipboard)
private val favoritesTab = Tab(ClipboardPanelSection.Favorites, R.string.favorites)
val deleteAllButton = ToolButton(ctx, R.drawable.ic_baseline_delete_sweep_24, theme).apply {
contentDescription = ctx.getString(R.string.delete_all)
}
private var onSectionSelected: ((ClipboardPanelSection) -> Unit)? = null
override val root = constraintLayout {
add(clipboardTab.root, lParams {
topOfParent()
startOfParent()
bottomOfParent()
before(favoritesTab.root)
})
add(favoritesTab.root, lParams {
topOfParent()
after(clipboardTab.root)
bottomOfParent()
before(deleteAllButton)
})
add(deleteAllButton, lParams(dp(40), dp(40)) {
centerVertically()
endOfParent()
})
}
init {
setActiveSection(ClipboardPanelSection.Clipboard)
}
fun setOnSectionSelectedListener(listener: (ClipboardPanelSection) -> Unit) {
onSectionSelected = listener
}
fun setActiveSection(section: ClipboardPanelSection) {
clipboardTab.setActive(section == ClipboardPanelSection.Clipboard)
favoritesTab.setActive(section == ClipboardPanelSection.Favorites)
}
fun setDeleteButtonShown(shown: Boolean) {
deleteAllButton.visibility = if (shown) View.VISIBLE else View.INVISIBLE
}
}

View File

@ -5,7 +5,6 @@
package org.fcitx.fcitx5.android.input.clipboard
import android.content.Context
import android.view.View
import android.widget.ViewAnimator
import androidx.transition.Fade
import androidx.transition.TransitionManager
@ -13,14 +12,12 @@ import org.fcitx.fcitx5.android.R
import org.fcitx.fcitx5.android.data.prefs.AppPrefs
import org.fcitx.fcitx5.android.data.theme.Theme
import org.fcitx.fcitx5.android.data.theme.ThemeManager
import org.fcitx.fcitx5.android.input.bar.ui.ToolButton
import splitties.dimensions.dp
import splitties.views.backgroundColor
import splitties.views.dsl.coordinatorlayout.coordinatorLayout
import splitties.views.dsl.coordinatorlayout.defaultLParams
import splitties.views.dsl.core.Ui
import splitties.views.dsl.core.add
import splitties.views.dsl.core.horizontalLayout
import splitties.views.dsl.core.lParams
import splitties.views.dsl.core.matchParent
import splitties.views.dsl.core.view
@ -37,10 +34,13 @@ class ClipboardUi(override val ctx: Context, private val theme: Theme) : Ui {
val emptyUi = ClipboardInstructionUi.Empty(ctx, theme)
val favoritesEmptyUi = ClipboardInstructionUi.FavoritesEmpty(ctx, theme)
val viewAnimator = view(::ViewAnimator) {
add(recyclerView, lParams(matchParent, matchParent))
add(emptyUi.root, lParams(matchParent, matchParent))
add(enableUi.root, lParams(matchParent, matchParent))
add(favoritesEmptyUi.root, lParams(matchParent, matchParent))
}
private val keyBorder by ThemeManager.prefs.keyBorder
@ -53,35 +53,28 @@ class ClipboardUi(override val ctx: Context, private val theme: Theme) : Ui {
add(viewAnimator, defaultLParams(matchParent, matchParent))
}
val deleteAllButton = ToolButton(ctx, R.drawable.ic_baseline_delete_sweep_24, theme).apply {
contentDescription = ctx.getString(R.string.delete_all)
}
val topBar = ClipboardTopBarUi(ctx, theme)
val extension = horizontalLayout {
add(deleteAllButton, lParams(dp(40), dp(40)))
}
val extension = topBar.root
private fun setDeleteButtonShown(enabled: Boolean) {
deleteAllButton.visibility = if (enabled) View.VISIBLE else View.INVISIBLE
}
fun switchUiByState(state: ClipboardStateMachine.State) {
fun switchUiByState(state: ClipboardStateMachine.State, showDeleteButton: Boolean) {
Timber.d("Switch clipboard to $state")
if (!disableAnimation)
TransitionManager.beginDelayedTransition(root, Fade().apply { duration = 100L })
when (state) {
ClipboardStateMachine.State.Normal -> {
viewAnimator.displayedChild = 0
setDeleteButtonShown(true)
}
ClipboardStateMachine.State.AddMore -> {
viewAnimator.displayedChild = 1
setDeleteButtonShown(false)
}
ClipboardStateMachine.State.EnableListening -> {
viewAnimator.displayedChild = 2
setDeleteButtonShown(false)
}
ClipboardStateMachine.State.NoFavorites -> {
viewAnimator.displayedChild = 3
}
}
topBar.setDeleteButtonShown(showDeleteButton)
}
}
}

View File

@ -16,6 +16,8 @@ import androidx.core.text.buildSpannedString
import androidx.core.text.color
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import androidx.paging.CombinedLoadStates
import androidx.paging.LoadState
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.recyclerview.widget.ItemTouchHelper
@ -25,28 +27,22 @@ 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.flow.collectLatest
import kotlinx.coroutines.launch
import org.fcitx.fcitx5.android.R
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.theme.ThemeManager
import org.fcitx.fcitx5.android.input.FcitxInputMethodService
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.BooleanKey.ClipboardDbEmpty
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.BooleanKey.ClipboardListeningEnabled
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.State.AddMore
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.State.EnableListening
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.State.Normal
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.TransitionEvent.ClipboardDbUpdated
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.TransitionEvent.ClipboardListeningUpdated
import org.fcitx.fcitx5.android.input.dependency.inputMethodService
import org.fcitx.fcitx5.android.input.dependency.theme
import org.fcitx.fcitx5.android.input.keyboard.KeyboardWindow
import org.fcitx.fcitx5.android.input.wm.InputWindow
import org.fcitx.fcitx5.android.input.wm.InputWindowManager
import org.fcitx.fcitx5.android.utils.AppUtil
import org.fcitx.fcitx5.android.utils.EventStateMachine
import org.fcitx.fcitx5.android.utils.item
import org.mechdancer.dependency.manager.must
import splitties.dimensions.dp
@ -64,13 +60,9 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
}
private var snackbarInstance: Snackbar? = null
private lateinit var stateMachine: EventStateMachine<ClipboardStateMachine.State, ClipboardStateMachine.TransitionEvent, ClipboardStateMachine.BooleanKey>
@Keep
private val clipboardEnabledListener = ManagedPreference.OnChangeListener<Boolean> { _, it ->
stateMachine.push(
ClipboardListeningUpdated, ClipboardListeningEnabled to it
)
private val clipboardEnabledListener = ManagedPreference.OnChangeListener<Boolean> { _, _ ->
renderUi()
}
private val prefs = AppPrefs.getInstance().clipboard
@ -81,11 +73,12 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
private val clipboardEntryRadius by ThemeManager.prefs.clipboardEntryRadius
private val clipboardEntriesPager by lazy {
Pager(PagingConfig(pageSize = 16)) { ClipboardManager.allEntries() }
}
private var adapterSubmitJob: Job? = null
private var selectedSection = ClipboardPanelSection.Clipboard
private var visibleEntriesEmpty = true
private var deleteAvailable = false
private val adapter: ClipboardAdapter by lazy {
object : ClipboardAdapter(
theme,
@ -100,6 +93,14 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
service.lifecycleScope.launch { ClipboardManager.unpin(id) }
}
override fun onFavorite(id: Int) {
service.lifecycleScope.launch { ClipboardManager.favorite(id) }
}
override fun onUnfavorite(id: Int) {
service.lifecycleScope.launch { ClipboardManager.unfavorite(id) }
}
override fun onEdit(id: Int) {
AppUtil.launchClipboardEdit(context, id)
}
@ -162,10 +163,11 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
enableUi.enableButton.setOnClickListener {
clipboardEnabledPref.setValue(true)
}
deleteAllButton.setOnClickListener {
service.lifecycleScope.launch {
promptDeleteAll(ClipboardManager.haveUnpinned())
}
topBar.setOnSectionSelectedListener {
showSection(it)
}
topBar.deleteAllButton.setOnClickListener {
promptDeleteAll()
}
}
}
@ -174,20 +176,20 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
private var promptMenu: PopupMenu? = null
private fun promptDeleteAll(skipPinned: Boolean) {
private fun promptDeleteAll() {
promptMenu?.dismiss()
promptMenu = PopupMenu(context, ui.deleteAllButton).apply {
promptMenu = PopupMenu(context, ui.topBar.deleteAllButton).apply {
menu.add(buildSpannedString {
bold {
color(context.styledColor(android.R.attr.colorAccent)) {
append(context.getString(if (skipPinned) R.string.delete_all_except_pinned else R.string.delete_all_pinned_items))
append(context.getString(R.string.delete_all_except_pinned_and_favorites))
}
}
}).isEnabled = false
menu.add(android.R.string.cancel)
menu.item(android.R.string.ok) {
service.lifecycleScope.launch {
val ids = ClipboardManager.deleteAll(skipPinned)
val ids = ClipboardManager.deleteAll()
showUndoSnackbar(*ids)
}
}
@ -202,6 +204,7 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
@SuppressLint("RestrictedApi")
private fun showUndoSnackbar(vararg id: Int) {
if (id.isEmpty()) return
id.forEach { pendingDeleteIds.add(it) }
val str = context.resources.getString(R.string.num_items_deleted, pendingDeleteIds.size)
snackbarInstance = Snackbar.make(snackbarCtx, ui.root, str, Snackbar.LENGTH_LONG)
@ -250,35 +253,75 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
}
}
override fun onAttached() {
val isEmpty = ClipboardManager.itemCount == 0
val isListening = clipboardEnabledPref.getValue()
val initialState = when {
!isListening -> EnableListening
isEmpty -> AddMore
else -> Normal
}
stateMachine = ClipboardStateMachine.new(initialState, isEmpty, isListening) {
ui.switchUiByState(it)
}
// manually switch to initial ui
ui.switchUiByState(initialState)
adapter.addLoadStateListener {
val empty = it.append.endOfPaginationReached && adapter.itemCount < 1
stateMachine.push(ClipboardDbUpdated, ClipboardDbEmpty to empty)
private val adapterLoadStateListener: (CombinedLoadStates) -> Unit = {
if (it.refresh is LoadState.NotLoading) {
visibleEntriesEmpty = it.append.endOfPaginationReached && adapter.itemCount < 1
refreshDeleteAvailability()
renderUi()
}
}
private fun showSection(section: ClipboardPanelSection) {
if (selectedSection == section && adapterSubmitJob?.isActive == true) return
selectedSection = section
visibleEntriesEmpty = false
deleteAvailable = false
ui.topBar.setActiveSection(section)
renderUi()
adapterSubmitJob?.cancel()
adapterSubmitJob = service.lifecycleScope.launch {
clipboardEntriesPager.flow.collect {
adapter.submitData(it)
val filter = when (section) {
ClipboardPanelSection.Clipboard -> ClipboardEntryFilter.All
ClipboardPanelSection.Favorites -> ClipboardEntryFilter.Favorites
}
Pager(PagingConfig(pageSize = 16)) { ClipboardManager.entries(filter) }
.flow
.collectLatest { adapter.submitData(it) }
}
}
private fun refreshDeleteAvailability() {
val section = selectedSection
service.lifecycleScope.launch {
val available = ClipboardManager.haveDeletable()
if (selectedSection == section) {
deleteAvailable = available
renderUi()
}
}
}
private fun renderUi() {
val state = ClipboardStateMachine.resolve(
clipboardEnabledPref.getValue(),
selectedSection,
visibleEntriesEmpty
)
ui.switchUiByState(
state,
state == ClipboardStateMachine.State.Normal &&
selectedSection == ClipboardPanelSection.Clipboard &&
deleteAvailable
)
}
override fun onAttached() {
selectedSection = ClipboardPanelSection.Clipboard
visibleEntriesEmpty = ClipboardManager.itemCount == 0
deleteAvailable = false
ui.topBar.setActiveSection(selectedSection)
adapter.addLoadStateListener(adapterLoadStateListener)
renderUi()
showSection(selectedSection)
clipboardEnabledPref.registerOnChangeListener(clipboardEnabledListener)
}
override fun onDetached() {
clipboardEnabledPref.unregisterOnChangeListener(clipboardEnabledListener)
adapter.removeLoadStateListener(adapterLoadStateListener)
adapter.onDetached()
adapterSubmitJob?.cancel()
adapterSubmitJob = null
promptMenu?.dismiss()
snackbarInstance?.dismiss()
}
@ -287,5 +330,9 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
context.getString(R.string.clipboard)
}
override val showTitle = false
override val showReturnButton = true
override fun onCreateBarExtension(): View = ui.extension
}
}

View File

@ -68,6 +68,8 @@ sealed class InputWindow : Dependent {
open val showTitle: Boolean = true
open val showReturnButton: Boolean = showTitle
open val title: String = ""
open fun onCreateBarExtension(): View? {
@ -83,4 +85,4 @@ sealed class InputWindow : Dependent {
override fun toString(): String = javaClass.name
}
}
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-License-Identifier: Apache-2.0
~ SPDX-FileCopyrightText: 2026 Google LLC
-->
<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="#FF000000"
android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z" />
</vector>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-License-Identifier: Apache-2.0
~ SPDX-FileCopyrightText: 2026 Google LLC
-->
<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="#FF000000"
android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4l-3.76,2.27 1,-4.28 -3.32,-2.88 4.38,-0.38L12,6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28z" />
</vector>

View File

@ -59,6 +59,9 @@
<string name="clear_clb_db">清空剪贴板数据库</string>
<string name="pin">置顶</string>
<string name="unpin">取消置顶</string>
<string name="favorite">收藏</string>
<string name="unfavorite">取消收藏</string>
<string name="favorites">收藏</string>
<string name="delete">删除</string>
<string name="clipboard">剪贴板</string>
<string name="keyboard_height">键盘高度</string>
@ -80,6 +83,7 @@
<string name="text_editing">文本编辑</string>
<string name="instruction_enable_clipboard_listening">要启用剪贴板管理,请在设置中开启“记录剪贴板历史”。</string>
<string name="instruction_copy">试着复制些东西</string>
<string name="instruction_favorite">长按剪贴板条目即可收藏</string>
<string name="clear">清空</string>
<string name="export">导出</string>
<string name="app_crash">应用崩溃</string>
@ -209,6 +213,7 @@
<string name="horizontal_candidate_always_fill">总是填充宽度</string>
<string name="inline_suggestions">显示内嵌自动填充建议</string>
<string name="delete_all_except_pinned">要删除所有未置顶条目吗?</string>
<string name="delete_all_except_pinned_and_favorites">要删除所有未置顶且未收藏的条目吗?</string>
<string name="num_items_deleted">已删除 %1$d 项</string>
<string name="delete_theme">删除主题</string>
<string name="delete_theme_msg">确定要删除主题“%1$s”吗</string>

View File

@ -59,6 +59,9 @@
<string name="clear_clb_db">清除剪貼簿資料庫</string>
<string name="pin">釘選</string>
<string name="unpin">取消釘選</string>
<string name="favorite">收藏</string>
<string name="unfavorite">取消收藏</string>
<string name="favorites">收藏</string>
<string name="delete">刪除</string>
<string name="clipboard">剪貼簿</string>
<string name="keyboard_height">鍵盤高度</string>
@ -80,6 +83,7 @@
<string name="text_editing">文字編輯</string>
<string name="instruction_enable_clipboard_listening">要使用剪貼簿管理,請在設定中啟用監聽剪貼簿</string>
<string name="instruction_copy">嘗試複製一些東西</string>
<string name="instruction_favorite">長按剪貼簿項目即可收藏</string>
<string name="clear">清除</string>
<string name="export">匯出</string>
<string name="app_crash">應用程式當機</string>
@ -209,6 +213,7 @@
<string name="horizontal_candidate_always_fill">總是填充寬度</string>
<string name="inline_suggestions">顯示內嵌自動填充建議</string>
<string name="delete_all_except_pinned">要刪除所有未置頂條目嗎?</string>
<string name="delete_all_except_pinned_and_favorites">要刪除所有未釘選且未收藏的項目嗎?</string>
<string name="num_items_deleted">已刪除 %1$d 項</string>
<string name="delete_theme">刪除主題</string>
<string name="delete_theme_msg">確定要刪除主題 “%1$s” 嗎?</string>

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name_release">Fcitx5</string>
<string name="app_name_debug">Fcitx5 (Debug)</string>
<string name="save">Save</string>
@ -59,6 +59,9 @@
<string name="clear_clb_db">Clear clipboard database</string>
<string name="pin">Pin</string>
<string name="unpin">Unpin</string>
<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="delete">Delete</string>
<string name="clipboard">Clipboard</string>
<string name="keyboard_height">Keyboard height</string>
@ -80,6 +83,7 @@
<string name="text_editing">Text Editing</string>
<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="clear">Clear</string>
<string name="export">Export</string>
<string name="app_crash">Application crashed</string>
@ -209,6 +213,7 @@
<string name="horizontal_candidate_always_fill">Always fill width</string>
<string name="inline_suggestions">Show inline autofill suggestions</string>
<string name="delete_all_except_pinned">Delete all items except pinned ones?</string>
<string name="delete_all_except_pinned_and_favorites" tools:ignore="MissingTranslation">Delete all items except pinned and favorite ones?</string>
<string name="num_items_deleted">%1$d item(s) deleted</string>
<string name="delete_theme">Delete Theme</string>
<string name="delete_theme_msg">Do you really want to delete theme \"%1$s\"?</string>

View File

@ -0,0 +1,47 @@
/*
* 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.input.clipboard.ClipboardPanelSection
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine
import org.junit.Assert.assertEquals
import org.junit.Test
class ClipboardStateMachineTest {
@Test
fun listeningDisabledAlwaysShowsEnableInstruction() {
ClipboardPanelSection.entries.forEach { section ->
listOf(true, false).forEach { empty ->
assertEquals(
ClipboardStateMachine.State.EnableListening,
ClipboardStateMachine.resolve(false, section, empty)
)
}
}
}
@Test
fun nonEmptySectionShowsEntries() {
ClipboardPanelSection.entries.forEach { section ->
assertEquals(
ClipboardStateMachine.State.Normal,
ClipboardStateMachine.resolve(true, section, false)
)
}
}
@Test
fun emptySectionShowsSectionSpecificInstruction() {
assertEquals(
ClipboardStateMachine.State.AddMore,
ClipboardStateMachine.resolve(true, ClipboardPanelSection.Clipboard, true)
)
assertEquals(
ClipboardStateMachine.State.NoFavorites,
ClipboardStateMachine.resolve(true, ClipboardPanelSection.Favorites, true)
)
}
}

View File

@ -66,7 +66,7 @@ class ThemeSerializationTest {
@Test
fun version2() {
// Version 2.0
// Version 2.0, outdated
val raw = """
{
"name":"",
@ -98,7 +98,7 @@ class ThemeSerializationTest {
}
""".trimIndent()
val (decoded, migrated) = raw.toCustomTheme()
Assert.assertEquals("Migration shouldn't happen", false, migrated)
Assert.assertEquals("Migration should happen", true, migrated)
Assert.assertEquals("Round trip", decoded, decoded.toJson().toCustomTheme().first)
}
}
}

View File

@ -0,0 +1,78 @@
# F5Clipboard 剪贴板增强计划
## 总体方案与阶段
基于当前 fork 的主 `app` 模块开发,保持上游兼容,不拆成独立插件或增强版应用。
- **阶段 1顶栏与收藏**(功能完成;全仓既有门禁问题待处理)
- **阶段 2智能管理**(待办:分类筛选、验证码和追踪口令清理)
- **阶段 3常用词**(待办:自定义短语、三字符匹配与一键补全)
上游相关需求:[fcitx5-android/fcitx5-android#456](https://github.com/fcitx5-android/fcitx5-android/issues/456)。
## 阶段 1顶栏与收藏
### 数据与接口
- Room 数据库由 v4 升到 v5为剪贴板条目增加 `favorite` 状态,并通过自动迁移保留旧数据。
- 使用 `ClipboardEntryFilter` 统一“全部”和“收藏”查询入口,为后续分类过滤保留扩展点。
- 使用独立的 `ClipboardPanelSection` 表示键盘面板顶栏页面;阶段 3 再扩展“常用词”。
- 阶段 1 不增加 `category``expiresAt` 等尚未使用的字段,也不修改插件 AIDL。
### 收藏与删除语义
- 收藏与置顶相互独立,同一条目可以同时置顶和收藏。
- 剪贴板页显示全部未删除条目;收藏页只显示收藏条目,均按置顶优先、时间倒序排列。
- 历史上限裁剪和一键清空只处理既未置顶、也未收藏的条目。
- 收藏属于软保护:用户仍可通过菜单或滑动手动删除,并可撤销。
- 重复复制已有文本时只更新时间,保留置顶和收藏状态。
### 顶栏与交互
- 顶栏布局:返回按钮 + “剪贴板 / 收藏”标签 + 清空按钮。
- 默认进入剪贴板页,不持久化上次标签。
- 收藏页隐藏清空按钮;没有收藏时显示独立空状态。
- 长按菜单提供“收藏/取消收藏”;条目使用星标展示状态。
- 新增英文、简体中文和繁体中文资源,其他语言回退到英文。
## 后续阶段预留
### 阶段 2智能管理
- 扩展剪贴板过滤接口,支持电话、邮箱、网址、验证码、追踪口令和其他类型。
- 在写入链路引入独立内容分析器,生成分类和可选过期时间;规则按文本判断,不依赖来源 App。
- 收藏和置顶覆盖自动过期规则。
- 开始阶段 2 前确认分类优先级、有效期、识别规则和误删恢复策略。
### 阶段 3常用词
- 顶栏扩展为“剪贴板 / 收藏 / 常用词”。
- 复用现有 `QuickPhraseManager``QuickPhraseEntry` 和快速输入文件格式。
- 提供键盘内新增、编辑、删除和直接上屏。
- 三字符预测接入现有 preedit/candidate 广播链路;开始阶段 3 前确认匹配和候选优先级。
## 阶段 1 验证清单
- [x] v4 数据库无损迁移到 v5旧条目默认未收藏。
- [x] 收藏切换、全部查询、收藏查询和排序正确。
- [x] 历史裁剪与一键清空跳过置顶及收藏。
- [x] 手动删除收藏条目及撤销正常。
- [x] 重复复制不会清除收藏状态。
- [x] 顶栏切换和各类空状态正确。
- [ ] 全仓门禁完全通过(`testDebugUnitTest``assembleDebug` 已通过;`lintDebug` 被 269 个既有错误阻塞)。
- [x] API 35 模拟器上的阶段 1 自动化与手工交互验证通过。
### 2026-07-30 验收记录
- `testDebugUnitTest``assembleDebug``assembleDebugAndroidTest` 均通过。
- API 35 上通过 AndroidJUnitRunner 直接执行阶段 1 的 6 个仪器测试Gradle `connectedDebugAndroidTest` 的 UTP 启动被本机离线依赖缓存阻塞。
- 手工验证了收藏/取消收藏、填充星标、收藏筛选、独立空状态、批量清空保护,以及重新进入时默认回到剪贴板页。
- `lintDebug` 完成扫描,本次改动没有命中 lint 报告;任务仍因仓库原有的 269 个错误失败,首个错误位于未改动的 `fragment_setup.xml`
- 仓库原有 `FcitxTest` 在 API 35 上进入首项测试后超过 90 秒没有完成;阶段 1 的独立仪器测试不受影响。
## 环境约定
- 使用 Android Platform 36、Build Tools 36.1.0、NDK 28.0.13004108、CMake 3.31.6。
- Windows 原生构建使用 MSYS2 UCRT64 的 ECM/gettext。
- 本地优先使用 Android Studio 自带 JBR 21CI 继续验证 JDK 17。
- Debug 包沿用 `.debug` 后缀,与正式版共存。

View File

@ -36,6 +36,7 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref =
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "room" }
androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
androidx-startup = { module = "androidx.startup:startup-runtime", version = "1.2.0" }
androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version = "1.1.0" }
material = { module = "com.google.android.material:material", version = "1.14.0" }