feat(clipboard): add content category filters

This commit is contained in:
fflow2023 2026-07-30 16:32:51 +08:00
parent 50960b4ec8
commit 1bbea77121
23 changed files with 1022 additions and 46 deletions

View File

@ -0,0 +1,92 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "393aeffb67cf740cc6b48e34d469cded",
"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, `category` TEXT NOT NULL DEFAULT 'Other', `classificationVersion` 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"
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'Other'"
},
{
"fieldPath": "classificationVersion",
"columnName": "classificationVersion",
"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, '393aeffb67cf740cc6b48e34d469cded')"
]
}
}

View File

@ -9,6 +9,8 @@ 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.ClipboardCategory
import org.fcitx.fcitx5.android.data.clipboard.ClipboardContentAnalyzer
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
@ -69,6 +71,30 @@ class ClipboardDatabaseTest {
}
}
@Test
fun migration5To6PreservesEntriesAndMarksThemForClassification() {
val name = "clipboard-category-migration-test"
migrationHelper.createDatabase(name, 5).apply {
execSQL(
"INSERT INTO clipboard (id, text, pinned, timestamp, type, deleted, sensitive, favorite) " +
"VALUES (1, 'person@example.com', 1, 123, 'text/plain', 0, 1, 1)"
)
close()
}
migrationHelper.runMigrationsAndValidate(name, 6, true).apply {
query("SELECT * FROM clipboard WHERE id=1").use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("person@example.com", cursor.getString(cursor.getColumnIndexOrThrow("text")))
assertEquals(1, cursor.getInt(cursor.getColumnIndexOrThrow("pinned")))
assertEquals(1, cursor.getInt(cursor.getColumnIndexOrThrow("favorite")))
assertEquals("Other", cursor.getString(cursor.getColumnIndexOrThrow("category")))
assertEquals(0, cursor.getInt(cursor.getColumnIndexOrThrow("classificationVersion")))
}
close()
}
}
@Test
fun favoritesAreFilteredAndKeepPinnedThenTimestampOrdering() = runBlocking {
insert(text = "old favorite", timestamp = 10, favorite = true)
@ -88,6 +114,49 @@ class ClipboardDatabaseTest {
)
}
@Test
fun categoryFiltersComposeWithFavoritesAndOrdering() = runBlocking {
insert("old phone", 10, category = ClipboardCategory.Phone)
insert("new phone", 30, category = ClipboardCategory.Phone)
insert("pinned phone", 20, pinned = true, category = ClipboardCategory.Phone)
insert("favorite phone", 40, favorite = true, category = ClipboardCategory.Phone)
insert("favorite email", 50, favorite = true, category = ClipboardCategory.Email)
val phones = dao.allEntries(ClipboardCategory.Phone).load(
PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)
) as PagingSource.LoadResult.Page<Int, ClipboardEntry>
assertEquals(
listOf("pinned phone", "favorite phone", "new phone", "old phone"),
phones.data.map { it.text }
)
val favoritePhones = dao.favoriteEntries(ClipboardCategory.Phone).load(
PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)
) as PagingSource.LoadResult.Page<Int, ClipboardEntry>
assertEquals(listOf("favorite phone"), favoritePhones.data.map { it.text })
}
@Test
fun outdatedEntriesCanBeReclassifiedWithoutChangingProtection() = runBlocking {
val id = insert(
"person@example.com",
10,
pinned = true,
favorite = true,
category = ClipboardCategory.Other,
classificationVersion = 0
)
assertEquals(listOf(id), dao.entriesToClassify(ClipboardContentAnalyzer.VERSION, 100).map { it.id })
dao.updateClassification(id, ClipboardCategory.Email, ClipboardContentAnalyzer.VERSION)
val updated = dao.get(id)!!
assertEquals(ClipboardCategory.Email, updated.category)
assertEquals(ClipboardContentAnalyzer.VERSION, updated.classificationVersion)
assertTrue(updated.pinned)
assertTrue(updated.favorite)
}
@Test
fun automaticDeletionTargetsOnlyUnprotectedEntries() = runBlocking {
val deletableId = insert(text = "deletable", timestamp = 10)
@ -128,7 +197,7 @@ class ClipboardDatabaseTest {
}
@Test
fun updatingTimestampPreservesPinnedAndFavoriteState() = runBlocking {
fun duplicateUpdateReclassifiesAndPreservesProtection() = runBlocking {
val id = insert(
text = "duplicate",
timestamp = 1,
@ -136,10 +205,17 @@ class ClipboardDatabaseTest {
favorite = true
)
dao.updateTime(id, 99)
dao.updateTimeAndClassification(
id,
99,
ClipboardCategory.Otp,
ClipboardContentAnalyzer.VERSION
)
val updated = dao.get(id)!!
assertEquals(99, updated.timestamp)
assertEquals(ClipboardCategory.Otp, updated.category)
assertEquals(ClipboardContentAnalyzer.VERSION, updated.classificationVersion)
assertTrue(updated.pinned)
assertTrue(updated.favorite)
}
@ -148,13 +224,17 @@ class ClipboardDatabaseTest {
text: String,
timestamp: Long,
pinned: Boolean = false,
favorite: Boolean = false
favorite: Boolean = false,
category: ClipboardCategory = ClipboardCategory.Other,
classificationVersion: Int = ClipboardContentAnalyzer.VERSION
): Int = dao.insert(
ClipboardEntry(
text = text,
pinned = pinned,
timestamp = timestamp,
favorite = favorite
favorite = favorite,
category = category,
classificationVersion = classificationVersion
)
).toInt()
}

View File

@ -0,0 +1,14 @@
/*
* 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 ClipboardCategory {
Phone,
Email,
Url,
Otp,
TrackingToken,
Other
}

View File

@ -0,0 +1,64 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.data.clipboard
import java.net.URI
import java.text.Normalizer
object ClipboardContentAnalyzer {
const val VERSION = 1
data class Analysis(
val category: ClipboardCategory,
val ruleId: String
)
private val trackingToken = Regex("""(?s).*[¥¥₤][^¥¥₤\r\n]{4,128}[¥¥₤].*""")
private val email = Regex(
"""(?i)^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$"""
)
private val bareUrl = Regex(
"""(?i)^(?:www\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}(?::\d{1,5})?(?:[/?#][^\s]*)?$"""
)
private val phoneCharacters = Regex("""^\+?[0-9()\-\s]+$""")
fun analyze(text: String): Analysis {
val normalized = Normalizer.normalize(text.trim(), Normalizer.Form.NFKC)
if (normalized.isEmpty()) return Analysis(ClipboardCategory.Other, "empty")
if (trackingToken.matches(normalized)) {
return Analysis(ClipboardCategory.TrackingToken, "tracking-token")
}
val exactDigits = normalized.all(Char::isDigit)
if (exactDigits && normalized.length in setOf(4, 6)) {
return Analysis(ClipboardCategory.Otp, "otp")
}
if (normalized.length <= 254 && email.matches(normalized)) {
return Analysis(ClipboardCategory.Email, "email")
}
if (isUrl(normalized)) {
return Analysis(ClipboardCategory.Url, "url")
}
if (phoneCharacters.matches(normalized)) {
val digitCount = normalized.count(Char::isDigit)
if (digitCount in 7..15) {
return Analysis(ClipboardCategory.Phone, "phone")
}
}
return Analysis(ClipboardCategory.Other, "other")
}
private fun isUrl(value: String): Boolean {
if (bareUrl.matches(value)) return true
val uri = runCatching { URI(value) }.getOrNull() ?: return false
if (uri.scheme?.lowercase() !in setOf("http", "https", "ftp")) return false
return !uri.host.isNullOrBlank() && !value.any(Char::isWhitespace)
}
}

View File

@ -4,7 +4,17 @@
*/
package org.fcitx.fcitx5.android.data.clipboard
enum class ClipboardEntryFilter {
data class ClipboardEntryFilter(
val scope: Scope = Scope.All,
val category: ClipboardCategory? = null
) {
enum class Scope {
All,
Favorites
}
companion object {
val All = ClipboardEntryFilter()
val Favorites = ClipboardEntryFilter(Scope.Favorites)
}
}

View File

@ -95,16 +95,19 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
enabledPref.registerOnChangeListener(enabledListener)
limitListener.onChange(limitPref.key, limitPref.getValue())
limitPref.registerOnChangeListener(limitListener)
launch { updateItemCount() }
launch {
updateItemCount()
reclassifyOutdatedEntries()
}
}
suspend fun get(id: Int) = clbDao.get(id)
suspend fun haveDeletable() = clbDao.haveDeletable()
fun entries(filter: ClipboardEntryFilter) = when (filter) {
ClipboardEntryFilter.All -> clbDao.allEntries()
ClipboardEntryFilter.Favorites -> clbDao.favoriteEntries()
fun entries(filter: ClipboardEntryFilter) = when (filter.scope) {
ClipboardEntryFilter.Scope.All -> clbDao.allEntries(filter.category)
ClipboardEntryFilter.Scope.Favorites -> clbDao.favoriteEntries(filter.category)
}
suspend fun pin(id: Int) = clbDao.updatePinStatus(id, true)
@ -116,10 +119,20 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
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))
}
clbDao.updateText(id, text)
val entry = clbDao.get(id) ?: return
val analysis = ClipboardContentAnalyzer.analyze(text)
val updated = entry.copy(
text = text,
category = analysis.category,
classificationVersion = ClipboardContentAnalyzer.VERSION
)
if (lastEntry?.id == id) updateLastEntry(updated)
clbDao.updateTextAndClassification(
id,
text,
analysis.category,
ClipboardContentAnalyzer.VERSION
)
}
suspend fun delete(id: Int) {
@ -172,12 +185,26 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
}
launch {
mutex.withLock {
val entry = ClipboardEntry.fromClipData(clip, transformer) ?: return@withLock
if (entry.text.isBlank()) return@withLock
val rawEntry = ClipboardEntry.fromClipData(clip, transformer) ?: return@withLock
if (rawEntry.text.isBlank()) return@withLock
val analysis = ClipboardContentAnalyzer.analyze(rawEntry.text)
val entry = rawEntry.copy(
category = analysis.category,
classificationVersion = ClipboardContentAnalyzer.VERSION
)
try {
clbDao.find(entry.text, entry.sensitive)?.let {
updateLastEntry(it.copy(timestamp = entry.timestamp))
clbDao.updateTime(it.id, entry.timestamp)
updateLastEntry(it.copy(
timestamp = entry.timestamp,
category = entry.category,
classificationVersion = ClipboardContentAnalyzer.VERSION
))
clbDao.updateTimeAndClassification(
it.id,
entry.timestamp,
entry.category,
ClipboardContentAnalyzer.VERSION
)
return@withLock
}
val insertedEntry = clbDb.withTransaction {
@ -209,4 +236,21 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
}
}
private suspend fun reclassifyOutdatedEntries() {
while (true) {
val entries = clbDao.entriesToClassify(ClipboardContentAnalyzer.VERSION, 100)
if (entries.isEmpty()) return
clbDb.withTransaction {
entries.forEach { entry ->
val analysis = ClipboardContentAnalyzer.analyze(entry.text)
clbDao.updateClassification(
entry.id,
analysis.category,
ClipboardContentAnalyzer.VERSION
)
}
}
}
}
}

View File

@ -0,0 +1,17 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.data.clipboard.db
import androidx.room.TypeConverter
import org.fcitx.fcitx5.android.data.clipboard.ClipboardCategory
class ClipboardConverters {
@TypeConverter
fun categoryToString(category: ClipboardCategory): String = category.name
@TypeConverter
fun stringToCategory(value: String): ClipboardCategory =
ClipboardCategory.entries.firstOrNull { it.name == value } ?: ClipboardCategory.Other
}

View File

@ -8,6 +8,7 @@ import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import org.fcitx.fcitx5.android.data.clipboard.ClipboardCategory
@Dao
interface ClipboardDao {
@ -20,11 +21,28 @@ interface ClipboardDao {
@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)
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET text=:text, category=:category, classificationVersion=:classificationVersion WHERE id=:id")
suspend fun updateTextAndClassification(
id: Int,
text: String,
category: ClipboardCategory,
classificationVersion: Int
)
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET timestamp=:timestamp WHERE id=:id")
suspend fun updateTime(id: Int, timestamp: Long)
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET timestamp=:timestamp, category=:category, classificationVersion=:classificationVersion WHERE id=:id")
suspend fun updateTimeAndClassification(
id: Int,
timestamp: Long,
category: ClipboardCategory,
classificationVersion: Int
)
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET category=:category, classificationVersion=:classificationVersion WHERE id=:id")
suspend fun updateClassification(
id: Int,
category: ClipboardCategory,
classificationVersion: Int
)
@Query("SELECT COUNT(*) FROM ${ClipboardEntry.TABLE_NAME} WHERE deleted=0")
suspend fun itemCount(): Int
@ -41,11 +59,17 @@ interface ClipboardDao {
@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 deleted=0 AND (:category IS NULL OR category=:category) ORDER BY pinned DESC, timestamp DESC")
fun allEntries(category: ClipboardCategory? = null): 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 favorite=1 AND deleted=0 AND (:category IS NULL OR category=:category) ORDER BY pinned DESC, timestamp DESC")
fun favoriteEntries(category: ClipboardCategory? = null): PagingSource<Int, ClipboardEntry>
@Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE deleted=0 AND classificationVersion<:classificationVersion LIMIT :limit")
suspend fun entriesToClassify(
classificationVersion: Int,
limit: Int
): List<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?

View File

@ -7,17 +7,20 @@ package org.fcitx.fcitx5.android.data.clipboard.db
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(
entities = [ClipboardEntry::class],
version = 5,
version = 6,
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3),
AutoMigration(from = 3, to = 4),
AutoMigration(from = 4, to = 5)
AutoMigration(from = 4, to = 5),
AutoMigration(from = 5, to = 6)
]
)
@TypeConverters(ClipboardConverters::class)
abstract class ClipboardDatabase : RoomDatabase() {
abstract fun clipboardDao(): ClipboardDao
}

View File

@ -10,6 +10,7 @@ import android.os.Build
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.fcitx.fcitx5.android.data.clipboard.ClipboardCategory
import org.fcitx.fcitx5.android.utils.timestamp
@Entity(tableName = ClipboardEntry.TABLE_NAME)
@ -27,7 +28,11 @@ data class ClipboardEntry(
@ColumnInfo(defaultValue = "0")
val sensitive: Boolean = false,
@ColumnInfo(defaultValue = "0")
val favorite: Boolean = false
val favorite: Boolean = false,
@ColumnInfo(defaultValue = "'Other'")
val category: ClipboardCategory = ClipboardCategory.Other,
@ColumnInfo(defaultValue = "0")
val classificationVersion: Int = 0
) {
companion object {
const val BULLET = ""

View File

@ -0,0 +1,92 @@
/*
* 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.widget.HorizontalScrollView
import org.fcitx.fcitx5.android.R
import org.fcitx.fcitx5.android.data.clipboard.ClipboardCategory
import org.fcitx.fcitx5.android.data.theme.Theme
import org.fcitx.fcitx5.android.data.theme.ThemeManager
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.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.textView
import splitties.views.dsl.core.view
import splitties.views.dsl.core.wrapContent
import splitties.views.gravityCenter
class ClipboardCategoryBarUi(override val ctx: Context, private val theme: Theme) : Ui {
private data class CategorySpec(
val category: ClipboardCategory?,
val textRes: Int
)
private val keyRipple by ThemeManager.prefs.keyRippleEffect
private val specs = listOf(
CategorySpec(null, R.string.clipboard_category_all),
CategorySpec(ClipboardCategory.Phone, R.string.clipboard_category_phone),
CategorySpec(ClipboardCategory.Email, R.string.clipboard_category_email),
CategorySpec(ClipboardCategory.Url, R.string.clipboard_category_url),
CategorySpec(ClipboardCategory.Otp, R.string.clipboard_category_otp),
CategorySpec(ClipboardCategory.TrackingToken, R.string.clipboard_category_tracking_token),
CategorySpec(ClipboardCategory.Other, R.string.clipboard_category_other)
)
private val tabs = specs.associate { spec ->
spec.category to textView {
setText(spec.textRes)
textSize = 14f
gravity = gravityCenter
isClickable = true
contentDescription = text
if (keyRipple) {
background = rippleDrawable(theme.keyPressHighlightColor)
} else {
foreground = pressHighlightDrawable(theme.keyPressHighlightColor)
}
setOnClickListener { onCategorySelected?.invoke(spec.category) }
}
}
private val content = horizontalLayout {
specs.forEach { spec ->
add(tabs.getValue(spec.category), lParams(dp(68), matchParent))
}
}
override val root = view(::HorizontalScrollView) {
isFillViewport = true
isHorizontalScrollBarEnabled = false
addView(content, lParams(wrapContent, matchParent))
}
private var onCategorySelected: ((ClipboardCategory?) -> Unit)? = null
init {
setActiveCategory(null)
}
fun setOnCategorySelectedListener(listener: (ClipboardCategory?) -> Unit) {
onCategorySelected = listener
}
fun setActiveCategory(category: ClipboardCategory?) {
tabs.forEach { (tabCategory, tab) ->
val active = tabCategory == category
tab.setTextColor(theme.keyTextColor.alpha(if (active) 1f else 0.5f))
tab.typeface = if (active) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
}
if (category == null) root.smoothScrollTo(0, 0)
}
}

View File

@ -0,0 +1,20 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.input.clipboard
import androidx.annotation.StringRes
import org.fcitx.fcitx5.android.R
import org.fcitx.fcitx5.android.data.clipboard.ClipboardCategory
@get:StringRes
val ClipboardCategory.stringRes: Int
get() = when (this) {
ClipboardCategory.Phone -> R.string.clipboard_category_phone
ClipboardCategory.Email -> R.string.clipboard_category_email
ClipboardCategory.Url -> R.string.clipboard_category_url
ClipboardCategory.Otp -> R.string.clipboard_category_otp
ClipboardCategory.TrackingToken -> R.string.clipboard_category_tracking_token
ClipboardCategory.Other -> R.string.clipboard_category_other
}

View File

@ -6,6 +6,7 @@ package org.fcitx.fcitx5.android.input.clipboard
import android.content.Context
import org.fcitx.fcitx5.android.R
import org.fcitx.fcitx5.android.data.clipboard.ClipboardCategory
import org.fcitx.fcitx5.android.data.theme.Theme
import splitties.dimensions.dp
import splitties.resources.drawable
@ -107,4 +108,38 @@ sealed class ClipboardInstructionUi(override val ctx: Context, protected val the
})
}
}
class FilteredEmpty(ctx: Context, theme: Theme) : ClipboardInstructionUi(ctx, theme) {
private val icon = imageView {
imageDrawable = drawable(R.drawable.ic_baseline_content_paste_24)!!.apply {
setTint(theme.altKeyTextColor)
}
}
private val instructionText = textView {
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()
})
}
fun setFilter(section: ClipboardPanelSection, category: ClipboardCategory) {
val format = when (section) {
ClipboardPanelSection.Clipboard -> R.string.instruction_no_category
ClipboardPanelSection.Favorites -> R.string.instruction_no_favorite_category
}
instructionText.text = ctx.getString(format, ctx.getString(category.stringRes))
}
}
}

View File

@ -10,16 +10,19 @@ object ClipboardStateMachine {
Normal,
AddMore,
NoFavorites,
NoFilteredEntries,
EnableListening
}
fun resolve(
listening: Boolean,
section: ClipboardPanelSection,
categorySelected: Boolean,
visibleEntriesEmpty: Boolean
): State = when {
!listening -> State.EnableListening
!visibleEntriesEmpty -> State.Normal
categorySelected -> State.NoFilteredEntries
section == ClipboardPanelSection.Favorites -> State.NoFavorites
else -> State.AddMore
}

View File

@ -5,6 +5,7 @@
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
@ -21,6 +22,7 @@ import splitties.views.dsl.core.add
import splitties.views.dsl.core.lParams
import splitties.views.dsl.core.matchParent
import splitties.views.dsl.core.view
import splitties.views.dsl.core.verticalLayout
import splitties.views.dsl.recyclerview.recyclerView
import timber.log.Timber
@ -36,11 +38,16 @@ class ClipboardUi(override val ctx: Context, private val theme: Theme) : Ui {
val favoritesEmptyUi = ClipboardInstructionUi.FavoritesEmpty(ctx, theme)
val filteredEmptyUi = ClipboardInstructionUi.FilteredEmpty(ctx, theme)
val categoryBar = ClipboardCategoryBarUi(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))
add(filteredEmptyUi.root, lParams(matchParent, matchParent))
}
private val keyBorder by ThemeManager.prefs.keyBorder
@ -50,7 +57,10 @@ class ClipboardUi(override val ctx: Context, private val theme: Theme) : Ui {
if (!keyBorder) {
backgroundColor = theme.barColor
}
add(viewAnimator, defaultLParams(matchParent, matchParent))
add(verticalLayout {
add(viewAnimator, lParams(matchParent, 0, weight = 1f))
add(categoryBar.root, lParams(matchParent, dp(40)))
}, defaultLParams(matchParent, matchParent))
}
val topBar = ClipboardTopBarUi(ctx, theme)
@ -74,7 +84,12 @@ class ClipboardUi(override val ctx: Context, private val theme: Theme) : Ui {
ClipboardStateMachine.State.NoFavorites -> {
viewAnimator.displayedChild = 3
}
ClipboardStateMachine.State.NoFilteredEntries -> {
viewAnimator.displayedChild = 4
}
}
categoryBar.root.visibility =
if (state == ClipboardStateMachine.State.EnableListening) View.GONE else View.VISIBLE
topBar.setDeleteButtonShown(showDeleteButton)
}
}

View File

@ -30,6 +30,7 @@ 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.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
@ -76,6 +77,7 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
private var adapterSubmitJob: Job? = null
private var selectedSection = ClipboardPanelSection.Clipboard
private var selectedCategory: ClipboardCategory? = null
private var visibleEntriesEmpty = true
private var deleteAvailable = false
@ -166,6 +168,9 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
topBar.setOnSectionSelectedListener {
showSection(it)
}
categoryBar.setOnCategorySelectedListener {
showCategory(it)
}
topBar.deleteAllButton.setOnClickListener {
promptDeleteAll()
}
@ -262,18 +267,30 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
}
private fun showSection(section: ClipboardPanelSection) {
if (selectedSection == section && adapterSubmitJob?.isActive == true) return
selectedSection = section
ui.topBar.setActiveSection(section)
submitEntries()
}
private fun showCategory(category: ClipboardCategory?) {
selectedCategory = category
ui.categoryBar.setActiveCategory(category)
submitEntries()
}
private fun submitEntries() {
visibleEntriesEmpty = false
deleteAvailable = false
ui.topBar.setActiveSection(section)
renderUi()
adapterSubmitJob?.cancel()
adapterSubmitJob = service.lifecycleScope.launch {
val filter = when (section) {
ClipboardPanelSection.Clipboard -> ClipboardEntryFilter.All
ClipboardPanelSection.Favorites -> ClipboardEntryFilter.Favorites
}
val filter = ClipboardEntryFilter(
scope = when (selectedSection) {
ClipboardPanelSection.Clipboard -> ClipboardEntryFilter.Scope.All
ClipboardPanelSection.Favorites -> ClipboardEntryFilter.Scope.Favorites
},
category = selectedCategory
)
Pager(PagingConfig(pageSize = 16)) { ClipboardManager.entries(filter) }
.flow
.collectLatest { adapter.submitData(it) }
@ -292,24 +309,29 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
}
private fun renderUi() {
selectedCategory?.let { ui.filteredEmptyUi.setFilter(selectedSection, it) }
val state = ClipboardStateMachine.resolve(
clipboardEnabledPref.getValue(),
selectedSection,
selectedCategory != null,
visibleEntriesEmpty
)
ui.switchUiByState(
state,
state == ClipboardStateMachine.State.Normal &&
selectedSection == ClipboardPanelSection.Clipboard &&
selectedCategory == null &&
deleteAvailable
)
}
override fun onAttached() {
selectedSection = ClipboardPanelSection.Clipboard
selectedCategory = null
visibleEntriesEmpty = ClipboardManager.itemCount == 0
deleteAvailable = false
ui.topBar.setActiveSection(selectedSection)
ui.categoryBar.setActiveCategory(selectedCategory)
adapter.addLoadStateListener(adapterLoadStateListener)
renderUi()
showSection(selectedSection)

View File

@ -84,6 +84,15 @@
<string name="instruction_enable_clipboard_listening">要启用剪贴板管理,请在设置中开启“记录剪贴板历史”。</string>
<string name="instruction_copy">试着复制些东西</string>
<string name="instruction_favorite">长按剪贴板条目即可收藏</string>
<string name="instruction_no_category">没有%1$s条目</string>
<string name="instruction_no_favorite_category">没有已收藏的%1$s条目</string>
<string name="clipboard_category_all">全部</string>
<string name="clipboard_category_phone">电话</string>
<string name="clipboard_category_email">邮箱</string>
<string name="clipboard_category_url">网址</string>
<string name="clipboard_category_otp">验证码</string>
<string name="clipboard_category_tracking_token">口令</string>
<string name="clipboard_category_other">其他</string>
<string name="clear">清空</string>
<string name="export">导出</string>
<string name="app_crash">应用崩溃</string>

View File

@ -84,6 +84,15 @@
<string name="instruction_enable_clipboard_listening">要使用剪貼簿管理,請在設定中啟用監聽剪貼簿</string>
<string name="instruction_copy">嘗試複製一些東西</string>
<string name="instruction_favorite">長按剪貼簿項目即可收藏</string>
<string name="instruction_no_category">沒有%1$s項目</string>
<string name="instruction_no_favorite_category">沒有已收藏的%1$s項目</string>
<string name="clipboard_category_all">全部</string>
<string name="clipboard_category_phone">電話</string>
<string name="clipboard_category_email">電子郵件</string>
<string name="clipboard_category_url">網址</string>
<string name="clipboard_category_otp">驗證碼</string>
<string name="clipboard_category_tracking_token">口令</string>
<string name="clipboard_category_other">其他</string>
<string name="clear">清除</string>
<string name="export">匯出</string>
<string name="app_crash">應用程式當機</string>

View File

@ -84,6 +84,15 @@
<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_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>
<string name="clipboard_category_phone" tools:ignore="MissingTranslation">Phone</string>
<string name="clipboard_category_email" tools:ignore="MissingTranslation">Email</string>
<string name="clipboard_category_url" tools:ignore="MissingTranslation">URL</string>
<string name="clipboard_category_otp" tools:ignore="MissingTranslation">Codes</string>
<string name="clipboard_category_tracking_token" tools:ignore="MissingTranslation">Tokens</string>
<string name="clipboard_category_other" tools:ignore="MissingTranslation">Other</string>
<string name="clear">Clear</string>
<string name="export">Export</string>
<string name="app_crash">Application crashed</string>

View File

@ -0,0 +1,62 @@
/*
* 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.clipboard.ClipboardCategory
import org.fcitx.fcitx5.android.data.clipboard.ClipboardContentAnalyzer
import org.junit.Assert.assertEquals
import org.junit.Test
class ClipboardContentAnalyzerTest {
@Test
fun classifiesSupportedContent() {
val cases = listOf(
"+86 138-0013-8000" to ClipboardCategory.Phone,
"person+tag@example.com" to ClipboardCategory.Email,
"https://example.com/path?q=1" to ClipboardCategory.Url,
"example.org/docs" to ClipboardCategory.Url,
"1234" to ClipboardCategory.Otp,
"123456" to ClipboardCategory.Otp,
"12345678" to ClipboardCategory.Phone,
"" to ClipboardCategory.Otp,
"复制这段内容后打开淘宝 ¥AbC123xy¥" to ClipboardCategory.TrackingToken,
"¥AbC123xy¥" to ClipboardCategory.TrackingToken,
"ordinary clipboard text" to ClipboardCategory.Other
)
cases.forEach { (text, expected) ->
assertEquals(text, expected, ClipboardContentAnalyzer.analyze(text).category)
}
}
@Test
fun avoidsAmbiguousFalsePositives() {
val cases = listOf(
"12345",
"order 123456",
"验证码123456",
"Verification code: 8042",
"验证码说明中没有数字",
"call me at 12345",
"email person@example.com in this sentence",
"visit https://example.com for details",
"这里提到了口令但没有结构",
"复制这段内容后打开淘宝领取优惠"
)
cases.forEach { text ->
assertEquals(text, ClipboardCategory.Other, ClipboardContentAnalyzer.analyze(text).category)
}
}
@Test
fun trackingTokenTakesPriorityOverEmbeddedDigits() {
assertEquals(
ClipboardCategory.TrackingToken,
ClipboardContentAnalyzer.analyze("验证码 123456复制后打开淘宝 ¥AbC123xy¥").category
)
assertEquals(ClipboardCategory.Other, ClipboardContentAnalyzer.analyze("OTP: 123456").category)
}
}

View File

@ -17,7 +17,7 @@ class ClipboardStateMachineTest {
listOf(true, false).forEach { empty ->
assertEquals(
ClipboardStateMachine.State.EnableListening,
ClipboardStateMachine.resolve(false, section, empty)
ClipboardStateMachine.resolve(false, section, false, empty)
)
}
}
@ -28,7 +28,7 @@ class ClipboardStateMachineTest {
ClipboardPanelSection.entries.forEach { section ->
assertEquals(
ClipboardStateMachine.State.Normal,
ClipboardStateMachine.resolve(true, section, false)
ClipboardStateMachine.resolve(true, section, false, false)
)
}
}
@ -37,11 +37,21 @@ class ClipboardStateMachineTest {
fun emptySectionShowsSectionSpecificInstruction() {
assertEquals(
ClipboardStateMachine.State.AddMore,
ClipboardStateMachine.resolve(true, ClipboardPanelSection.Clipboard, true)
ClipboardStateMachine.resolve(true, ClipboardPanelSection.Clipboard, false, true)
)
assertEquals(
ClipboardStateMachine.State.NoFavorites,
ClipboardStateMachine.resolve(true, ClipboardPanelSection.Favorites, true)
ClipboardStateMachine.resolve(true, ClipboardPanelSection.Favorites, false, true)
)
}
@Test
fun emptyCategoryShowsFilteredInstructionInBothSections() {
ClipboardPanelSection.entries.forEach { section ->
assertEquals(
ClipboardStateMachine.State.NoFilteredEntries,
ClipboardStateMachine.resolve(true, section, true, true)
)
}
}
}

View File

@ -0,0 +1,246 @@
# 剪贴板分类手工测试样本
这份文档用于 PR2 的模拟器手工测试。每次只复制代码块中的一条文本,然后打开小企鹅输入法的剪贴板,切换底部分类栏检查它出现的位置。
建议先复制每组中的“应命中”样本,再复制“应归入其他”的反例。顶栏切换到“收藏”后,还可以收藏其中一条,再验证“收藏 × 分类”组合筛选。
## 验证码
以下应归入“验证码”:
```text
1234
```
```text
8042
```
```text
0000
```
```text
123456
```
```text
000000
```
```text
```
```text
```
以下不应归入“验证码”,而应归入“其他”:
```text
123
```
```text
12345
```
```text
验证码123456
```
```text
验证码 8042
```
```text
OTP: 123456
```
```text
Your verification code is 8042
```
```text
订单号 123456
```
```text
12 34
```
```text
12-34
```
## 电话号码
以下应归入“电话”:
```text
13800138000
```
```text
+86 138-0013-8000
```
```text
(010) 8888-8888
```
```text
12345678
```
以下应归入“其他”:
```text
电话13800138000
```
```text
13800138000 转 1234
```
```text
12345
```
## 邮箱
以下应归入“邮箱”:
```text
person@example.com
```
```text
person+tag@example.com
```
```text
first.last@sub.example.co.uk
```
以下应归入“其他”:
```text
邮箱person@example.com
```
```text
请联系 person@example.com 获取帮助
```
```text
person@example
```
## 网址
以下应归入“网址”:
```text
https://example.com/path?q=1
```
```text
http://example.org
```
```text
ftp://files.example.org/archive.zip
```
```text
example.org/docs
```
```text
www.example.com
```
以下应归入“其他”:
```text
访问 https://example.com 获取详情
```
```text
example
```
```text
https://example.com 帮助
```
## 追踪口令
当前版本刻意采用保守规则。以下应归入“口令”:
```text
¥AbC123xy¥
```
```text
复制这段内容后打开淘宝 ¥AbC123xy¥
```
```text
¥a1b2c3d4¥
```
```text
商品分享 ₤Token9876₤ 请打开应用
```
以下应归入“其他”:
```text
复制这段内容后打开淘宝领取优惠
```
```text
这里提到了口令但没有结构
```
```text
¥abc¥
```
```text
¥只有左边符号
```
## 普通文本
以下均应归入“其他”:
```text
北京市朝阳区示例路 100 号
```
```text
这是一个普通的剪贴板条目。
```
```text
账号 example_user_01
```
```text
明天下午三点开会
```
## 推荐交互检查
1. 在“剪贴板”页依次切换七个底部分类,检查条目只出现在预期分类中。
2. 收藏一个验证码和一个网址,切换到“收藏”页,再分别选择“验证码”和“网址”。
3. 选择一个没有条目的分类,检查显示分类专用空状态。
4. 退出剪贴板面板后重新进入,检查恢复为“剪贴板 + 全部”。
5. 重复复制同一条文本,检查它移动到最前,但收藏和置顶状态不丢失。
6. 将条目编辑成另一类内容,检查它从旧分类移动到新分类。
7. 检查底部栏可以横向滚动,窄屏下“其他”仍可访问。
8. 检查批量清空、滑动删除和撤销行为与阶段 1 相同;本版本不应发生任何自动删除。

View File

@ -5,7 +5,7 @@
基于当前 fork 的主 `app` 模块开发,保持上游兼容,不拆成独立插件或增强版应用。
- **阶段 1顶栏与收藏**(功能完成;全仓既有门禁问题待处理)
- **阶段 2智能管理**待办:分类筛选、验证码和追踪口令清理
- **阶段 2智能管理**PR2 分类与底部筛选已完成、待提交;自动清理另作后续 PR
- **阶段 3常用词**(待办:自定义短语、三字符匹配与一键补全)
上游相关需求:[fcitx5-android/fcitx5-android#456](https://github.com/fcitx5-android/fcitx5-android/issues/456)。
@ -39,10 +39,79 @@
### 阶段 2智能管理
- 扩展剪贴板过滤接口,支持电话、邮箱、网址、验证码、追踪口令和其他类型。
- 在写入链路引入独立内容分析器,生成分类和可选过期时间;规则按文本判断,不依赖来源 App。
- 收藏和置顶覆盖自动过期规则。
- 开始阶段 2 前确认分类优先级、有效期、识别规则和误删恢复策略。
阶段 2 按可独立审查、可独立取舍的 PR 拆分。当前 PR2 只完成无破坏性的分类和底部筛选 UI不包含任何自动删除行为因此安装 PR2 后,条目的保存和删除语义与阶段 1 完全一致。可选自动清理留到后续独立 PR。
#### 2A数据模型与内容分析器
- Room 数据库由 v5 升到 v6新增
- `category`:稳定的字符串枚举,包含 `Phone``Email``Url``Otp``TrackingToken``Other`
- `classificationVersion`:分析规则版本,用于未来规则升级后安全地重新分析旧数据。
- v5→v6 迁移先把旧条目标记为 `Other / classificationVersion=0`,数据库打开后再分批重新分析;迁移 SQL 本身不运行复杂正则,避免阻塞启动和迁移失败。
- 新增纯 Kotlin 的 `ClipboardContentAnalyzer`,输入原始文本,输出唯一分类及命中的规则 ID。分析器不依赖 Room、UI、自动清理设置、sensitive 标记或来源 App可通过表驱动单元测试独立验证。
- 后续自动清理不得维护第二套验证码/口令识别器:是否具备删除资格必须直接复用本分析器的分类结果。
- 原文保持不变;仅对 `trim()` 后的临时文本做判断不自动改写电话号码、URL 或口令。
- 当前 `ClipboardEntryFilter` 从枚举扩展为组合查询对象:
- `scope`:全部或收藏。
- `category`:全部类别或单个类别。
这样不会为“收藏 × 分类”制造大量枚举值,阶段 3 也可继续沿用。
分类优先级采用:`TrackingToken > Otp > Email > Url > Phone > Other`。高特征、短生命周期内容优先,防止淘口令被当作普通文本、验证码被当作电话号码。
具体识别规则:
- **追踪口令**:首版只匹配成对的 `¥…¥`/`¥…¥`/`₤…₤` 高置信度结构。单独出现“口令”“复制后打开淘宝/天猫”等说明文字不命中;京东、拼多多等格式在取得稳定样本后再以独立规则加入,避免凭关键词误判。
- **验证码**:去除首尾空白并完成全角归一化后,内容必须恰好是 4 位或 6 位纯数字。系统“仅复制验证码”可以直接命中;`验证码123456`、短信正文、带字母的代码和 5 位数字归入 `Other`。715 位纯数字仍按电话号码规则判断。分类不依赖 sensitive 标记。
- **邮箱**:去除首尾空白后必须整体匹配邮箱格式,不从长段落中截取局部邮箱。
- **网址**:必须是完整 URL或可安全补全协议的完整域名首版不把包含 URL 的整段分享文案归类为网址。
- **电话**:仅允许 `+`、数字、空格、连字符和括号,去除分隔符后为 715 位数字;其中纯 4 位和纯 6 位数字已优先归为验证码,其他满足长度的纯数字仍可归为电话。
- **其他**:没有满足上述完整规则的内容。
写入和更新语义:
- 新条目在同一事务内完成分析和写入。
- 重复复制已有文本时更新时间并重新计算分类,同时保留置顶、收藏状态。
- 编辑条目后重新分析,置顶和收藏状态保持不变。
- 规则版本升级时仅重分析 `classificationVersion` 较旧的有效条目,并限制每批数量,避免大历史库卡顿。
#### 2B分类筛选界面
- 分类筛选栏固定在剪贴板面板底部、位于条目列表下方,使用可横向滚动的紧凑栏:`全部、电话、邮箱、网址、验证码、口令、其他`。顶栏仍然只有返回按钮、“剪贴板 / 收藏”和清空按钮,不增加第二行顶栏。
- 分类筛选同时适用于剪贴板页和收藏页;切换顶栏页面时保留当前分类,退出并重新进入剪贴板窗口时恢复为“剪贴板 + 全部”。
- 筛选栏只改变查询,不改变排序;仍按置顶优先、时间倒序排列。
- 每种分类提供独立空状态,例如“没有已收藏的网址”,不复用“尝试复制内容”。
- 条目本身暂不增加醒目的分类角标,避免卡片过于拥挤;首版只通过筛选栏表达分类。后续如需要,可在长按菜单中显示只读分类信息。
- 英文、简体中文、繁体中文提供完整资源,其他语言继续回退英文。
#### 后续独立 PR可选自动过期与保护
- 自动清理首版只作用于被分析器分类为 `Otp``TrackingToken` 的条目。电话、邮箱、网址和其他类别永不过期。
- 剪贴板设置增加两个独立开关和时长:
- 自动删除验证码:默认关闭;开启后允许配置 11440 分钟,初始建议值 10 分钟。
- 自动删除追踪口令:默认关闭;开启后允许配置 1720 小时,初始建议值 24 小时。
- 两个开关默认均为关闭。只要对应开关未开启,该类别不会生成有效期,清理调度器和数据库删除查询也必须再次检查开关,保证关闭状态下完全不发生自动删除。
- 不增加“最近清理”、恢复区、`deletedAt``deletionReason`,也不改变现有手动删除 + Snackbar 撤销逻辑。自动过期到达后按 ID 直接从数据库彻底删除。
- 开启某类自动删除时,已有的同类未保护条目统一从开启时刻开始计算新的有效期,不追溯旧时间立即删除。
- 修改删除时长时,尚未过期的同类未保护条目统一从修改时刻重新计时;关闭开关时立即把该类别所有待过期条目的 `expiresAt` 清空。
- 置顶或收藏任一操作都会清空该条目的 `expiresAt`,使其永久保留;之后取消保护也不会自动恢复旧有效期。若用户确实需要重新启用过期,只需再次复制该内容。
- 自动清理触发点:数据库初始化、收到新剪贴板、进入剪贴板窗口、修改相关设置,以及输入法进程存活期间最近一个 `expiresAt` 到达时。首版不引入 WorkManager因此不承诺应用进程被系统杀死时准点到秒清理但下次启动会立即补清。
- 自动清理不会弹出打断输入的 Snackbar这是用户预先启用并配置时间后的直接删除行为。
- 开发者设置中的彻底清库行为保持不变。
#### PR2 验收标准
- v5→v6 迁移无损,旧条目可后台重分析,置顶和收藏状态保持不变。
- 分类规则对中英文样例、边界长度、全角符号、换行和误判反例均有表驱动测试。
- 全部/收藏与七种分类组合查询、分页和排序正确。
- 重复复制和编辑会重新分类,且不会清除置顶或收藏。
- PR2 不增加 `expiresAt`、清理调度器或自动删除设置,不改变阶段 1 的删除行为。
- API 35 模拟器上验证深浅主题、横竖屏、窄屏筛选栏、敏感内容遮罩及大批量分页。
#### 明确不纳入阶段 2
- 不读取或记录剪贴板来源 App分类完全依据文本也不使用 Android sensitive 标记影响分类。
- 不自动修改 URL、电话号码或口令原文ClearURLs 插件仍保持独立。
- 不引入机器学习模型、联系人匹配、云端规则或联网分析。
- 不实现阶段 3 的常用词、候选预测或新的插件 AIDL。
### 阶段 3常用词
@ -70,6 +139,28 @@
- `lintDebug` 完成扫描,本次改动没有命中 lint 报告;任务仍因仓库原有的 269 个错误失败,首个错误位于未改动的 `fragment_setup.xml`
- 仓库原有 `FcitxTest` 在 API 35 上进入首项测试后超过 90 秒没有完成;阶段 1 的独立仪器测试不受影响。
## PR2 验证清单
- [x] Room v5→v6 无损迁移,旧条目默认 `Other / classificationVersion=0`
- [x] 旧条目分批重分类,置顶和收藏状态不变。
- [x] 新增、重复复制和编辑会按同一分析器更新分类。
- [x] 纯 4 位和纯 6 位数字归为验证码带说明文字、5 位数字等反例不命中。
- [x] 口令首版仅识别成对货币符号结构,宽泛说明文字不命中。
- [x] 全部/收藏与各分类组合查询及排序正确。
- [x] 底部分类栏切换、分类空状态和重新进入时复位正确。
- [x] PR2 不包含任何自动过期字段、设置或删除逻辑。
- [x] `testDebugUnitTest``assembleDebug``assembleDebugAndroidTest` 通过。
- [x] API 35 上 9 项剪贴板数据库仪器测试通过。
- [x] `git diff --check` 通过。
- [ ] `lintDebug` 全任务通过(当前被项目构建插件的 `cxxAbiModel` 缺失阻断,尚未进入 lint 扫描)。
### 2026-07-30 PR2 验收记录
- 分类分析器的 3 组表驱动单元测试全部通过,覆盖 4/6 位验证码、全角数字、电话边界、带文本验证码反例及保守口令规则。
- API 35 上 9/9 仪器测试通过,覆盖 v4→v5、v5→v6 迁移,分类筛选与收藏组合,以及重复复制重分类后保留保护状态。
- 已在模拟器中检查底部栏布局、电话筛选和“收藏 × 分类”独立空状态;验证码规则的最终修订由最新单元测试及重新构建的 APK 覆盖。
- `lintDebug` 和排除首个失败任务后的 `lintAnalyzeDebug` 都在 lint 扫描前被现有原生构建插件阻断:`installProjectConfig` / `installProjectPrebuiltAssets` 无法取得 `cxxAbiModel`。该问题不来自 PR2 源码编译、APK 打包及仪器测试均已通过。
## 环境约定
- 使用 Android Platform 36、Build Tools 36.1.0、NDK 28.0.13004108、CMake 3.31.6。