mirror of
https://github.com/fcitx5-android/fcitx5-android.git
synced 2026-08-02 04:44:35 +08:00
feat(clipboard): add content category filters
This commit is contained in:
parent
50960b4ec8
commit
1bbea77121
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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?
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 = "•"
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
"123456" 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
246
docs/clipboard-category-manual-test-samples.md
Normal file
246
docs/clipboard-category-manual-test-samples.md
Normal file
@ -0,0 +1,246 @@
|
||||
# 剪贴板分类手工测试样本
|
||||
|
||||
这份文档用于 PR2 的模拟器手工测试。每次只复制代码块中的一条文本,然后打开小企鹅输入法的剪贴板,切换底部分类栏检查它出现的位置。
|
||||
|
||||
建议先复制每组中的“应命中”样本,再复制“应归入其他”的反例。顶栏切换到“收藏”后,还可以收藏其中一条,再验证“收藏 × 分类”组合筛选。
|
||||
|
||||
## 验证码
|
||||
|
||||
以下应归入“验证码”:
|
||||
|
||||
```text
|
||||
1234
|
||||
```
|
||||
|
||||
```text
|
||||
8042
|
||||
```
|
||||
|
||||
```text
|
||||
0000
|
||||
```
|
||||
|
||||
```text
|
||||
123456
|
||||
```
|
||||
|
||||
```text
|
||||
000000
|
||||
```
|
||||
|
||||
```text
|
||||
9876
|
||||
```
|
||||
|
||||
```text
|
||||
123456
|
||||
```
|
||||
|
||||
以下不应归入“验证码”,而应归入“其他”:
|
||||
|
||||
```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 相同;本版本不应发生任何自动删除。
|
||||
@ -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`。7–15 位纯数字仍按电话号码规则判断。分类不依赖 sensitive 标记。
|
||||
- **邮箱**:去除首尾空白后必须整体匹配邮箱格式,不从长段落中截取局部邮箱。
|
||||
- **网址**:必须是完整 URL,或可安全补全协议的完整域名;首版不把包含 URL 的整段分享文案归类为网址。
|
||||
- **电话**:仅允许 `+`、数字、空格、连字符和括号,去除分隔符后为 7–15 位数字;其中纯 4 位和纯 6 位数字已优先归为验证码,其他满足长度的纯数字仍可归为电话。
|
||||
- **其他**:没有满足上述完整规则的内容。
|
||||
|
||||
写入和更新语义:
|
||||
|
||||
- 新条目在同一事务内完成分析和写入。
|
||||
- 重复复制已有文本时更新时间并重新计算分类,同时保留置顶、收藏状态。
|
||||
- 编辑条目后重新分析,置顶和收藏状态保持不变。
|
||||
- 规则版本升级时仅重分析 `classificationVersion` 较旧的有效条目,并限制每批数量,避免大历史库卡顿。
|
||||
|
||||
#### 2B:分类筛选界面
|
||||
|
||||
- 分类筛选栏固定在剪贴板面板底部、位于条目列表下方,使用可横向滚动的紧凑栏:`全部、电话、邮箱、网址、验证码、口令、其他`。顶栏仍然只有返回按钮、“剪贴板 / 收藏”和清空按钮,不增加第二行顶栏。
|
||||
- 分类筛选同时适用于剪贴板页和收藏页;切换顶栏页面时保留当前分类,退出并重新进入剪贴板窗口时恢复为“剪贴板 + 全部”。
|
||||
- 筛选栏只改变查询,不改变排序;仍按置顶优先、时间倒序排列。
|
||||
- 每种分类提供独立空状态,例如“没有已收藏的网址”,不复用“尝试复制内容”。
|
||||
- 条目本身暂不增加醒目的分类角标,避免卡片过于拥挤;首版只通过筛选栏表达分类。后续如需要,可在长按菜单中显示只读分类信息。
|
||||
- 英文、简体中文、繁体中文提供完整资源,其他语言继续回退英文。
|
||||
|
||||
#### 后续独立 PR:可选自动过期与保护
|
||||
|
||||
- 自动清理首版只作用于被分析器分类为 `Otp` 或 `TrackingToken` 的条目。电话、邮箱、网址和其他类别永不过期。
|
||||
- 剪贴板设置增加两个独立开关和时长:
|
||||
- 自动删除验证码:默认关闭;开启后允许配置 1–1440 分钟,初始建议值 10 分钟。
|
||||
- 自动删除追踪口令:默认关闭;开启后允许配置 1–720 小时,初始建议值 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。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user