feat(clipboard): add optional automatic expiry

This commit is contained in:
fflow2023 2026-07-30 20:24:40 +08:00
parent 1bbea77121
commit d6190d133e
15 changed files with 682 additions and 27 deletions

View File

@ -0,0 +1,98 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "63be4c6384ccbd4eaf0e0e110fb69e33",
"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, `expiresAt` INTEGER DEFAULT NULL)",
"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"
},
{
"fieldPath": "expiresAt",
"columnName": "expiresAt",
"affinity": "INTEGER",
"defaultValue": "NULL"
}
],
"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, '63be4c6384ccbd4eaf0e0e110fb69e33')"
]
}
}

View File

@ -95,6 +95,29 @@ class ClipboardDatabaseTest {
}
}
@Test
fun migration6To7PreservesEntriesAndDefaultsExpiryToNull() {
val name = "clipboard-expiry-migration-test"
migrationHelper.createDatabase(name, 6).apply {
execSQL(
"INSERT INTO clipboard " +
"(id, text, pinned, timestamp, type, deleted, sensitive, favorite, category, classificationVersion) " +
"VALUES (1, '123456', 0, 123, 'text/plain', 0, 0, 0, 'Otp', 1)"
)
close()
}
migrationHelper.runMigrationsAndValidate(name, 7, true).apply {
query("SELECT * FROM clipboard WHERE id=1").use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("123456", cursor.getString(cursor.getColumnIndexOrThrow("text")))
assertEquals("Otp", cursor.getString(cursor.getColumnIndexOrThrow("category")))
assertTrue(cursor.isNull(cursor.getColumnIndexOrThrow("expiresAt")))
}
close()
}
}
@Test
fun favoritesAreFilteredAndKeepPinnedThenTimestampOrdering() = runBlocking {
insert(text = "old favorite", timestamp = 10, favorite = true)
@ -209,24 +232,102 @@ class ClipboardDatabaseTest {
id,
99,
ClipboardCategory.Otp,
ClipboardContentAnalyzer.VERSION
ClipboardContentAnalyzer.VERSION,
expiresAt = 999
)
val updated = dao.get(id)!!
assertEquals(99, updated.timestamp)
assertEquals(ClipboardCategory.Otp, updated.category)
assertEquals(ClipboardContentAnalyzer.VERSION, updated.classificationVersion)
assertNull(updated.expiresAt)
assertTrue(updated.pinned)
assertTrue(updated.favorite)
}
@Test
fun expiryResetAndDeletionOnlyAffectEligibleEntries() = runBlocking {
val expiredOtp = insert(
text = "1234",
timestamp = 1,
category = ClipboardCategory.Otp,
expiresAt = 50
)
val futureOtp = insert(
text = "123456",
timestamp = 2,
category = ClipboardCategory.Otp,
expiresAt = 500
)
val expiredToken = insert(
text = "¥token¥",
timestamp = 3,
category = ClipboardCategory.TrackingToken,
expiresAt = 50
)
insert(
text = "pinned code",
timestamp = 4,
pinned = true,
category = ClipboardCategory.Otp,
expiresAt = 50
)
insert(
text = "favorite code",
timestamp = 5,
favorite = true,
category = ClipboardCategory.Otp,
expiresAt = 50
)
assertEquals(
1,
dao.deleteExpired(listOf(ClipboardCategory.Otp), now = 100)
)
assertNull(dao.get(expiredOtp))
assertTrue(dao.get(futureOtp) != null)
assertTrue(dao.get(expiredToken) != null)
assertEquals(50L, dao.nearestExpiry(listOf(ClipboardCategory.TrackingToken)))
dao.resetExpiry(ClipboardCategory.Otp, expiresAt = 900)
assertEquals(900L, dao.get(futureOtp)?.expiresAt)
dao.clearExpiry(ClipboardCategory.Otp)
assertNull(dao.get(futureOtp)?.expiresAt)
}
@Test
fun protectingEntryClearsExpiryAndUnprotectingDoesNotRestoreIt() = runBlocking {
val pinnedId = insert(
text = "1234",
timestamp = 1,
category = ClipboardCategory.Otp,
expiresAt = 100
)
dao.updatePinStatus(pinnedId, true)
assertNull(dao.get(pinnedId)?.expiresAt)
dao.updatePinStatus(pinnedId, false)
assertNull(dao.get(pinnedId)?.expiresAt)
val favoriteId = insert(
text = "123456",
timestamp = 2,
category = ClipboardCategory.Otp,
expiresAt = 100
)
dao.updateFavoriteStatus(favoriteId, true)
assertNull(dao.get(favoriteId)?.expiresAt)
dao.updateFavoriteStatus(favoriteId, false)
assertNull(dao.get(favoriteId)?.expiresAt)
}
private suspend fun insert(
text: String,
timestamp: Long,
pinned: Boolean = false,
favorite: Boolean = false,
category: ClipboardCategory = ClipboardCategory.Other,
classificationVersion: Int = ClipboardContentAnalyzer.VERSION
classificationVersion: Int = ClipboardContentAnalyzer.VERSION,
expiresAt: Long? = null
): Int = dao.insert(
ClipboardEntry(
text = text,
@ -234,7 +335,8 @@ class ClipboardDatabaseTest {
timestamp = timestamp,
favorite = favorite,
category = category,
classificationVersion = classificationVersion
classificationVersion = classificationVersion,
expiresAt = expiresAt
)
).toInt()
}

View File

@ -0,0 +1,82 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android
import android.content.ClipData
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.fcitx.fcitx5.android.data.clipboard.ClipboardCategory
import org.fcitx.fcitx5.android.data.clipboard.ClipboardManager
import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardEntry
import org.fcitx.fcitx5.android.data.prefs.AppPrefs
import org.fcitx.fcitx5.android.utils.clipboardManager
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ClipboardExpiryManagerTest {
@Test
fun otpExpiryFollowsSettingsAndProtection() = runBlocking {
val prefs = AppPrefs.getInstance().clipboard
val originalEnabled = prefs.clipboardOtpAutoDelete.getValue()
val originalMinutes = prefs.clipboardOtpAutoDeleteMinutes.getValue()
val context = InstrumentationRegistry.getInstrumentation().targetContext
try {
prefs.clipboardOtpAutoDeleteMinutes.setValue(1)
prefs.clipboardOtpAutoDelete.setValue(true)
ClipboardManager.nukeTable()
val copiedAt = System.currentTimeMillis()
context.clipboardManager.setPrimaryClip(ClipData.newPlainText("", "654321"))
ClipboardManager.onPrimaryClipChanged()
val inserted = awaitEntry("654321")
assertEquals(ClipboardCategory.Otp, inserted.category)
assertNotNull(inserted.expiresAt)
assertTrue(inserted.expiresAt!! in (copiedAt + 55_000)..(copiedAt + 70_000))
ClipboardManager.favorite(inserted.id)
assertNull(ClipboardManager.get(inserted.id)?.expiresAt)
ClipboardManager.unfavorite(inserted.id)
assertNull(ClipboardManager.get(inserted.id)?.expiresAt)
context.clipboardManager.setPrimaryClip(ClipData.newPlainText("", "654321"))
ClipboardManager.onPrimaryClipChanged()
assertNotNull(awaitEntry("654321", requireExpiry = true).expiresAt)
prefs.clipboardOtpAutoDelete.setValue(false)
withTimeout(5_000) {
while (ClipboardManager.get(inserted.id)?.expiresAt != null) delay(50)
}
context.clipboardManager.setPrimaryClip(ClipData.newPlainText("", "1234"))
ClipboardManager.onPrimaryClipChanged()
assertNull(awaitEntry("1234").expiresAt)
} finally {
prefs.clipboardOtpAutoDelete.setValue(originalEnabled)
prefs.clipboardOtpAutoDeleteMinutes.setValue(originalMinutes)
ClipboardManager.nukeTable()
}
}
private suspend fun awaitEntry(
text: String,
requireExpiry: Boolean = false
): ClipboardEntry = withTimeout(5_000) {
while (true) {
val entry = ClipboardManager.lastEntry
if (entry?.text == text && (!requireExpiry || entry.expiresAt != null)) {
return@withTimeout entry
}
delay(50)
}
error("unreachable")
}
}

View File

@ -0,0 +1,39 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.data.clipboard
object ClipboardExpiryPolicy {
const val MILLIS_PER_MINUTE = 60_000L
const val MILLIS_PER_HOUR = 60 * MILLIS_PER_MINUTE
data class Settings(
val otpEnabled: Boolean,
val otpMinutes: Int,
val trackingTokenEnabled: Boolean,
val trackingTokenHours: Int
)
fun expiresAt(
category: ClipboardCategory,
copiedAt: Long,
pinned: Boolean,
favorite: Boolean,
settings: Settings
): Long? {
if (pinned || favorite) return null
val lifetime = when (category) {
ClipboardCategory.Otp ->
if (settings.otpEnabled) settings.otpMinutes * MILLIS_PER_MINUTE else null
ClipboardCategory.TrackingToken ->
if (settings.trackingTokenEnabled) {
settings.trackingTokenHours * MILLIS_PER_HOUR
} else {
null
}
else -> null
}
return lifetime?.let { copiedAt + it }
}
}

View File

@ -12,7 +12,9 @@ import androidx.room.Room
import androidx.room.withTransaction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -39,6 +41,8 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
private val clipboardManager = appContext.clipboardManager
private val mutex = Mutex()
private val expiryMutex = Mutex()
private var expiryTimer: Job? = null
var itemCount: Int = 0
private set
@ -59,7 +63,8 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
onUpdateListeners.remove(listener)
}
private val enabledPref = AppPrefs.getInstance().clipboard.clipboardListening
private val prefs = AppPrefs.getInstance().clipboard
private val enabledPref = prefs.clipboardListening
@Keep
private val enabledListener = ManagedPreference.OnChangeListener<Boolean> { _, value ->
@ -70,13 +75,37 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
}
}
private val limitPref = AppPrefs.getInstance().clipboard.clipboardHistoryLimit
private val limitPref = prefs.clipboardHistoryLimit
@Keep
private val limitListener = ManagedPreference.OnChangeListener<Int> { _, _ ->
launch { removeOutdated() }
}
@Keep
private val otpExpiryEnabledListener =
ManagedPreference.OnChangeListener<Boolean> { _, _ ->
launch { refreshExpiryPolicy(ClipboardCategory.Otp) }
}
@Keep
private val otpExpiryDurationListener =
ManagedPreference.OnChangeListener<Int> { _, _ ->
launch { refreshExpiryPolicy(ClipboardCategory.Otp) }
}
@Keep
private val trackingTokenExpiryEnabledListener =
ManagedPreference.OnChangeListener<Boolean> { _, _ ->
launch { refreshExpiryPolicy(ClipboardCategory.TrackingToken) }
}
@Keep
private val trackingTokenExpiryDurationListener =
ManagedPreference.OnChangeListener<Int> { _, _ ->
launch { refreshExpiryPolicy(ClipboardCategory.TrackingToken) }
}
var lastEntry: ClipboardEntry? = null
private fun updateLastEntry(entry: ClipboardEntry) {
@ -95,9 +124,18 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
enabledPref.registerOnChangeListener(enabledListener)
limitListener.onChange(limitPref.key, limitPref.getValue())
limitPref.registerOnChangeListener(limitListener)
prefs.clipboardOtpAutoDelete.registerOnChangeListener(otpExpiryEnabledListener)
prefs.clipboardOtpAutoDeleteMinutes.registerOnChangeListener(otpExpiryDurationListener)
prefs.clipboardTrackingTokenAutoDelete.registerOnChangeListener(
trackingTokenExpiryEnabledListener
)
prefs.clipboardTrackingTokenAutoDeleteHours.registerOnChangeListener(
trackingTokenExpiryDurationListener
)
launch {
updateItemCount()
reclassifyOutdatedEntries()
initializeExpiryMaintenance()
}
}
@ -110,11 +148,17 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
ClipboardEntryFilter.Scope.Favorites -> clbDao.favoriteEntries(filter.category)
}
suspend fun pin(id: Int) = clbDao.updatePinStatus(id, true)
suspend fun pin(id: Int) = expiryMutex.withLock {
clbDao.updatePinStatus(id, true)
scheduleNextExpiryLocked()
}
suspend fun unpin(id: Int) = clbDao.updatePinStatus(id, false)
suspend fun favorite(id: Int) = clbDao.updateFavoriteStatus(id, true)
suspend fun favorite(id: Int) = expiryMutex.withLock {
clbDao.updateFavoriteStatus(id, true)
scheduleNextExpiryLocked()
}
suspend fun unfavorite(id: Int) = clbDao.updateFavoriteStatus(id, false)
@ -124,32 +168,43 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
val updated = entry.copy(
text = text,
category = analysis.category,
classificationVersion = ClipboardContentAnalyzer.VERSION
classificationVersion = ClipboardContentAnalyzer.VERSION,
expiresAt = expiryFor(
analysis.category,
System.currentTimeMillis(),
entry.pinned,
entry.favorite
)
)
if (lastEntry?.id == id) updateLastEntry(updated)
clbDao.updateTextAndClassification(
id,
text,
analysis.category,
ClipboardContentAnalyzer.VERSION
ClipboardContentAnalyzer.VERSION,
updated.expiresAt
)
refreshExpiryTimer()
}
suspend fun delete(id: Int) {
clbDao.markAsDeleted(id)
updateItemCount()
refreshExpiryTimer()
}
suspend fun deleteAll(): IntArray {
val ids = clbDao.findDeletableIds()
clbDao.markAsDeleted(*ids)
updateItemCount()
refreshExpiryTimer()
return ids
}
suspend fun undoDelete(vararg ids: Int) {
clbDao.undoDelete(*ids)
updateItemCount()
refreshExpiryTimer()
}
suspend fun realDelete() {
@ -161,6 +216,7 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
clbDb.clearAllTables()
updateItemCount()
}
refreshExpiryTimer()
}
private var lastClipTimestamp = -1L
@ -190,21 +246,36 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
val analysis = ClipboardContentAnalyzer.analyze(rawEntry.text)
val entry = rawEntry.copy(
category = analysis.category,
classificationVersion = ClipboardContentAnalyzer.VERSION
classificationVersion = ClipboardContentAnalyzer.VERSION,
expiresAt = expiryFor(
analysis.category,
rawEntry.timestamp,
rawEntry.pinned,
rawEntry.favorite
)
)
try {
clbDao.find(entry.text, entry.sensitive)?.let {
updateLastEntry(it.copy(
val updated = it.copy(
timestamp = entry.timestamp,
category = entry.category,
classificationVersion = ClipboardContentAnalyzer.VERSION
))
classificationVersion = ClipboardContentAnalyzer.VERSION,
expiresAt = expiryFor(
entry.category,
entry.timestamp,
it.pinned,
it.favorite
)
)
updateLastEntry(updated)
clbDao.updateTimeAndClassification(
it.id,
entry.timestamp,
entry.category,
ClipboardContentAnalyzer.VERSION
ClipboardContentAnalyzer.VERSION,
updated.expiresAt
)
refreshExpiryTimer()
return@withLock
}
val insertedEntry = clbDb.withTransaction {
@ -215,6 +286,7 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
}
updateLastEntry(insertedEntry)
updateItemCount()
refreshExpiryTimer()
} catch (exception: Exception) {
Timber.w("Failed to update clipboard database: $exception")
updateLastEntry(entry)
@ -253,4 +325,105 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
}
}
suspend fun cleanupExpired() {
expiryMutex.withLock {
deleteExpiredLocked()
scheduleNextExpiryLocked()
}
}
private fun expirySettings() = ClipboardExpiryPolicy.Settings(
otpEnabled = prefs.clipboardOtpAutoDelete.getValue(),
otpMinutes = prefs.clipboardOtpAutoDeleteMinutes.getValue(),
trackingTokenEnabled = prefs.clipboardTrackingTokenAutoDelete.getValue(),
trackingTokenHours = prefs.clipboardTrackingTokenAutoDeleteHours.getValue()
)
private fun expiryFor(
category: ClipboardCategory,
copiedAt: Long,
pinned: Boolean,
favorite: Boolean
) = ClipboardExpiryPolicy.expiresAt(
category,
copiedAt,
pinned,
favorite,
expirySettings()
)
private fun enabledExpiryCategories(
settings: ClipboardExpiryPolicy.Settings = expirySettings()
) = buildList {
if (settings.otpEnabled) add(ClipboardCategory.Otp)
if (settings.trackingTokenEnabled) add(ClipboardCategory.TrackingToken)
}
private suspend fun initializeExpiryMaintenance() {
expiryMutex.withLock {
val settings = expirySettings()
if (!settings.otpEnabled) clbDao.clearExpiry(ClipboardCategory.Otp)
if (!settings.trackingTokenEnabled) {
clbDao.clearExpiry(ClipboardCategory.TrackingToken)
}
deleteExpiredLocked(settings)
scheduleNextExpiryLocked(settings)
}
}
private suspend fun refreshExpiryPolicy(category: ClipboardCategory) {
expiryMutex.withLock {
val settings = expirySettings()
val expiresAt = ClipboardExpiryPolicy.expiresAt(
category,
System.currentTimeMillis(),
pinned = false,
favorite = false,
settings
)
if (expiresAt == null) {
clbDao.clearExpiry(category)
} else {
clbDao.resetExpiry(category, expiresAt)
}
deleteExpiredLocked(settings)
scheduleNextExpiryLocked(settings)
}
}
private suspend fun refreshExpiryTimer() {
expiryMutex.withLock {
scheduleNextExpiryLocked()
}
}
private suspend fun deleteExpiredLocked(
settings: ClipboardExpiryPolicy.Settings = expirySettings()
) {
val categories = enabledExpiryCategories(settings)
if (categories.isEmpty()) return
if (clbDao.deleteExpired(categories, System.currentTimeMillis()) > 0) {
updateItemCount()
}
}
private suspend fun scheduleNextExpiryLocked(
settings: ClipboardExpiryPolicy.Settings = expirySettings()
) {
expiryTimer?.cancel()
expiryTimer = null
val categories = enabledExpiryCategories(settings)
if (categories.isEmpty()) return
val expiresAt = clbDao.nearestExpiry(categories) ?: return
val waitMillis = (expiresAt - System.currentTimeMillis()).coerceAtLeast(0)
expiryTimer = launch {
delay(waitMillis)
expiryMutex.withLock {
expiryTimer = null
deleteExpiredLocked()
scheduleNextExpiryLocked()
}
}
}
}

View File

@ -15,26 +15,28 @@ interface ClipboardDao {
@Insert
suspend fun insert(clipboardEntry: ClipboardEntry): Long
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET pinned=:pinned WHERE id=:id")
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET pinned=:pinned, expiresAt=CASE WHEN :pinned THEN NULL ELSE expiresAt END WHERE id=:id")
suspend fun updatePinStatus(id: Int, pinned: Boolean)
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET favorite=:favorite WHERE id=:id")
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET favorite=:favorite, expiresAt=CASE WHEN :favorite THEN NULL ELSE expiresAt END WHERE id=:id")
suspend fun updateFavoriteStatus(id: Int, favorite: Boolean)
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET text=:text, category=:category, classificationVersion=:classificationVersion WHERE id=:id")
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET text=:text, category=:category, classificationVersion=:classificationVersion, expiresAt=CASE WHEN pinned=1 OR favorite=1 THEN NULL ELSE :expiresAt END WHERE id=:id")
suspend fun updateTextAndClassification(
id: Int,
text: String,
category: ClipboardCategory,
classificationVersion: Int
classificationVersion: Int,
expiresAt: Long?
)
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET timestamp=:timestamp, category=:category, classificationVersion=:classificationVersion WHERE id=:id")
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET timestamp=:timestamp, category=:category, classificationVersion=:classificationVersion, expiresAt=CASE WHEN pinned=1 OR favorite=1 THEN NULL ELSE :expiresAt END WHERE id=:id")
suspend fun updateTimeAndClassification(
id: Int,
timestamp: Long,
category: ClipboardCategory,
classificationVersion: Int
classificationVersion: Int,
expiresAt: Long?
)
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET category=:category, classificationVersion=:classificationVersion WHERE id=:id")
@ -77,6 +79,18 @@ interface ClipboardDao {
@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 expiresAt=:expiresAt WHERE category=:category AND pinned=0 AND favorite=0 AND deleted=0")
suspend fun resetExpiry(category: ClipboardCategory, expiresAt: Long)
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET expiresAt=NULL WHERE category=:category")
suspend fun clearExpiry(category: ClipboardCategory)
@Query("SELECT MIN(expiresAt) FROM ${ClipboardEntry.TABLE_NAME} WHERE category IN (:categories) AND pinned=0 AND favorite=0 AND deleted=0 AND expiresAt IS NOT NULL")
suspend fun nearestExpiry(categories: List<ClipboardCategory>): Long?
@Query("DELETE FROM ${ClipboardEntry.TABLE_NAME} WHERE category IN (:categories) AND pinned=0 AND favorite=0 AND deleted=0 AND expiresAt IS NOT NULL AND expiresAt<=:now")
suspend fun deleteExpired(categories: List<ClipboardCategory>, now: Long): Int
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET deleted=1 WHERE id in (:ids)")
suspend fun markAsDeleted(vararg ids: Int)

View File

@ -11,13 +11,14 @@ import androidx.room.TypeConverters
@Database(
entities = [ClipboardEntry::class],
version = 6,
version = 7,
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3),
AutoMigration(from = 3, to = 4),
AutoMigration(from = 4, to = 5),
AutoMigration(from = 5, to = 6)
AutoMigration(from = 5, to = 6),
AutoMigration(from = 6, to = 7)
]
)
@TypeConverters(ClipboardConverters::class)

View File

@ -32,7 +32,9 @@ data class ClipboardEntry(
@ColumnInfo(defaultValue = "'Other'")
val category: ClipboardCategory = ClipboardCategory.Other,
@ColumnInfo(defaultValue = "0")
val classificationVersion: Int = 0
val classificationVersion: Int = 0,
@ColumnInfo(defaultValue = "NULL")
val expiresAt: Long? = null
) {
companion object {
const val BULLET = ""

View File

@ -355,6 +355,34 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) {
val clipboardMaskSensitive = switch(
R.string.clipboard_mask_sensitive, "clipboard_mask_sensitive", true
) { clipboardListening.getValue() }
val clipboardOtpAutoDelete = switch(
R.string.clipboard_otp_auto_delete,
"clipboard_otp_auto_delete",
false,
R.string.clipboard_auto_delete_summary
) { clipboardListening.getValue() }
val clipboardOtpAutoDeleteMinutes = int(
R.string.clipboard_otp_auto_delete_minutes,
"clipboard_otp_auto_delete_minutes",
10,
1,
1440,
"min"
) { clipboardListening.getValue() && clipboardOtpAutoDelete.getValue() }
val clipboardTrackingTokenAutoDelete = switch(
R.string.clipboard_tracking_token_auto_delete,
"clipboard_tracking_token_auto_delete",
false,
R.string.clipboard_auto_delete_summary
) { clipboardListening.getValue() }
val clipboardTrackingTokenAutoDeleteHours = int(
R.string.clipboard_tracking_token_auto_delete_hours,
"clipboard_tracking_token_auto_delete_hours",
24,
1,
720,
"h"
) { clipboardListening.getValue() && clipboardTrackingTokenAutoDelete.getValue() }
}
inner class Symbols : ManagedPreferenceCategory(R.string.emoji_and_symbols, sharedPreferences) {
@ -442,4 +470,4 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) {
fun getInstance() = instance!!
}
}
}

View File

@ -326,6 +326,7 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
}
override fun onAttached() {
service.lifecycleScope.launch { ClipboardManager.cleanupExpired() }
selectedSection = ClipboardPanelSection.Clipboard
selectedCategory = null
visibleEntriesEmpty = ClipboardManager.itemCount == 0

View File

@ -93,6 +93,11 @@
<string name="clipboard_category_otp">验证码</string>
<string name="clipboard_category_tracking_token">口令</string>
<string name="clipboard_category_other">其他</string>
<string name="clipboard_otp_auto_delete">自动删除验证码</string>
<string name="clipboard_otp_auto_delete_minutes">验证码保留时间</string>
<string name="clipboard_tracking_token_auto_delete">自动删除追踪口令</string>
<string name="clipboard_tracking_token_auto_delete_hours">追踪口令保留时间</string>
<string name="clipboard_auto_delete_summary">置顶或收藏的条目始终保留</string>
<string name="clear">清空</string>
<string name="export">导出</string>
<string name="app_crash">应用崩溃</string>

View File

@ -93,6 +93,11 @@
<string name="clipboard_category_otp">驗證碼</string>
<string name="clipboard_category_tracking_token">口令</string>
<string name="clipboard_category_other">其他</string>
<string name="clipboard_otp_auto_delete">自動刪除驗證碼</string>
<string name="clipboard_otp_auto_delete_minutes">驗證碼保留時間</string>
<string name="clipboard_tracking_token_auto_delete">自動刪除追蹤口令</string>
<string name="clipboard_tracking_token_auto_delete_hours">追蹤口令保留時間</string>
<string name="clipboard_auto_delete_summary">釘選或收藏的項目一律保留</string>
<string name="clear">清除</string>
<string name="export">匯出</string>
<string name="app_crash">應用程式當機</string>

View File

@ -93,6 +93,11 @@
<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="clipboard_otp_auto_delete" tools:ignore="MissingTranslation">Automatically delete verification codes</string>
<string name="clipboard_otp_auto_delete_minutes" tools:ignore="MissingTranslation">Verification code retention</string>
<string name="clipboard_tracking_token_auto_delete" tools:ignore="MissingTranslation">Automatically delete tracking tokens</string>
<string name="clipboard_tracking_token_auto_delete_hours" tools:ignore="MissingTranslation">Tracking token retention</string>
<string name="clipboard_auto_delete_summary" tools:ignore="MissingTranslation">Pinned and favorite items are always kept</string>
<string name="clear">Clear</string>
<string name="export">Export</string>
<string name="app_crash">Application crashed</string>

View File

@ -0,0 +1,79 @@
/*
* 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.ClipboardExpiryPolicy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ClipboardExpiryPolicyTest {
private val enabled = ClipboardExpiryPolicy.Settings(
otpEnabled = true,
otpMinutes = 10,
trackingTokenEnabled = true,
trackingTokenHours = 24
)
@Test
fun schedulesOnlyEnabledShortLivedCategories() {
assertEquals(
601_000L,
ClipboardExpiryPolicy.expiresAt(
ClipboardCategory.Otp, 1_000, false, false, enabled
)
)
assertEquals(
86_401_000L,
ClipboardExpiryPolicy.expiresAt(
ClipboardCategory.TrackingToken, 1_000, false, false, enabled
)
)
ClipboardCategory.entries
.filterNot { it == ClipboardCategory.Otp || it == ClipboardCategory.TrackingToken }
.forEach {
assertNull(
ClipboardExpiryPolicy.expiresAt(it, 1_000, false, false, enabled)
)
}
}
@Test
fun disabledSettingsAreExactlyNoOp() {
val disabled = enabled.copy(
otpEnabled = false,
trackingTokenEnabled = false
)
assertNull(
ClipboardExpiryPolicy.expiresAt(
ClipboardCategory.Otp, 1_000, false, false, disabled
)
)
assertNull(
ClipboardExpiryPolicy.expiresAt(
ClipboardCategory.TrackingToken, 1_000, false, false, disabled
)
)
}
@Test
fun pinnedAndFavoriteEntriesNeverExpire() {
assertNull(
ClipboardExpiryPolicy.expiresAt(
ClipboardCategory.Otp, 1_000, pinned = true, favorite = false, enabled
)
)
assertNull(
ClipboardExpiryPolicy.expiresAt(
ClipboardCategory.TrackingToken,
1_000,
pinned = false,
favorite = true,
enabled
)
)
}
}

View File

@ -5,7 +5,7 @@
基于当前 fork 的主 `app` 模块开发,保持上游兼容,不拆成独立插件或增强版应用。
- **阶段 1顶栏与收藏**(功能完成;全仓既有门禁问题待处理)
- **阶段 2智能管理**PR2 分类与底部筛选已完成、待提交;自动清理另作后续 PR
- **阶段 2智能管理**分类、底部筛选和可选自动清理均已完成
- **阶段 3常用词**(待办:自定义短语、三字符匹配与一键补全)
上游相关需求:[fcitx5-android/fcitx5-android#456](https://github.com/fcitx5-android/fcitx5-android/issues/456)。
@ -39,7 +39,7 @@
### 阶段 2智能管理
阶段 2 按可独立审查、可独立取舍的 PR 拆分。当前 PR2 只完成无破坏性的分类和底部筛选 UI不包含任何自动删除行为因此安装 PR2 后,条目的保存和删除语义与阶段 1 完全一致。可选自动清理留到后续独立 PR
阶段 2 的分类与自动清理保持模块化实现。分类本身不改变删除语义;只有用户明确开启对应的自动删除开关后,验证码或追踪口令才会获得有效期
#### 2A数据模型与内容分析器
@ -82,7 +82,7 @@
- 条目本身暂不增加醒目的分类角标,避免卡片过于拥挤;首版只通过筛选栏表达分类。后续如需要,可在长按菜单中显示只读分类信息。
- 英文、简体中文、繁体中文提供完整资源,其他语言继续回退英文。
#### 后续独立 PR:可选自动过期与保护
#### 2C:可选自动过期与保护
- 自动清理首版只作用于被分析器分类为 `Otp``TrackingToken` 的条目。电话、邮箱、网址和其他类别永不过期。
- 剪贴板设置增加两个独立开关和时长:
@ -161,6 +161,27 @@
- 已在模拟器中检查底部栏布局、电话筛选和“收藏 × 分类”独立空状态;验证码规则的最终修订由最新单元测试及重新构建的 APK 覆盖。
- `lintDebug` 和排除首个失败任务后的 `lintAnalyzeDebug` 都在 lint 扫描前被现有原生构建插件阻断:`installProjectConfig` / `installProjectPrebuiltAssets` 无法取得 `cxxAbiModel`。该问题不来自 PR2 源码编译、APK 打包及仪器测试均已通过。
## 阶段 2C 验证清单
- [x] Room v6→v7 无损迁移,旧条目的 `expiresAt` 默认为空。
- [x] 两类自动删除开关默认关闭;关闭时新条目不产生有效期,已有有效期会被清空。
- [x] 开启开关或修改时长时,同类未保护条目从当前时刻重新计时。
- [x] 只有分析器分类为 `Otp``TrackingToken` 的条目可自动过期。
- [x] 置顶或收藏会清空有效期,取消保护不会恢复旧有效期。
- [x] 到期条目直接删除,不进入手动删除的撤销流程。
- [x] 进程内定时器、应用初始化和进入剪贴板窗口都会补做过期清理。
- [x] 英文、简体中文、繁体中文设置资源和禁用状态正确。
- [x] `testDebugUnitTest``assembleDebug``assembleDebugAndroidTest` 通过。
- [x] API 35 上 12 项数据库仪器测试和 1 项管理器集成测试通过。
- [x] `git diff --check` 通过。
### 2026-07-30 阶段 2C 验收记录
- 新增 3 项纯 Kotlin 过期策略测试,验证默认关闭、分类范围及置顶/收藏保护。
- API 35 数据库测试覆盖 v6→v7 迁移、按类别删除、最近到期时间、重新计时和保护状态切换。
- 管理器集成测试验证了复制 6 位验证码后生成有效期、收藏后取消有效期、再次复制后重新计时,以及关闭开关后清空有效期且后续复制不再安排删除。
- 模拟器设置页已检查:两个开关默认关闭,时长项仅在对应开关开启时可编辑,默认值分别为 10 分钟和 24 小时。
## 环境约定
- 使用 Android Platform 36、Build Tools 36.1.0、NDK 28.0.13004108、CMake 3.31.6。