mirror of
https://github.com/fcitx5-android/fcitx5-android.git
synced 2026-08-02 04:44:35 +08:00
feat(clipboard): add optional automatic expiry
This commit is contained in:
parent
1bbea77121
commit
d6190d133e
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 = "•"
|
||||
|
||||
@ -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!!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user