mirror of
https://github.com/fcitx5-android/fcitx5-android.git
synced 2026-08-02 04:44:35 +08:00
feat(clipboard): add favorites panel
This commit is contained in:
parent
bcb694384d
commit
50960b4ec8
@ -56,6 +56,8 @@ android {
|
||||
@Suppress("UnstableApiUsage")
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
sourceSets.getByName("androidTest").assets.directories.add("$projectDir/schemas")
|
||||
}
|
||||
|
||||
fcitxComponent {
|
||||
@ -127,6 +129,7 @@ dependencies {
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.test.runner)
|
||||
androidTestImplementation(libs.androidx.test.rules)
|
||||
androidTestImplementation(libs.androidx.room.testing)
|
||||
androidTestImplementation(libs.androidx.lifecycle.testing)
|
||||
androidTestImplementation(libs.junit)
|
||||
}
|
||||
@ -140,3 +143,9 @@ configurations {
|
||||
exclude(group = "com.louiscad.splitties", module = "splitties-systemservices")
|
||||
}
|
||||
}
|
||||
|
||||
// descriptor.json is generated inside the main assets directory. Gradle 9 requires
|
||||
// consumers of that directory to declare the producing task explicitly.
|
||||
tasks.matching { it.name.startsWith("lint") || it.name.endsWith("LintReportModel") }.configureEach {
|
||||
dependsOn("generateDataDescriptor")
|
||||
}
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 5,
|
||||
"identityHash": "01175120dc4178de02503037e673b3ce",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "clipboard",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL DEFAULT -1, `type` TEXT NOT NULL DEFAULT 'text/plain', `deleted` INTEGER NOT NULL DEFAULT 0, `sensitive` INTEGER NOT NULL DEFAULT 0, `favorite` INTEGER NOT NULL DEFAULT 0)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "text",
|
||||
"columnName": "text",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pinned",
|
||||
"columnName": "pinned",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "-1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'text/plain'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "deleted",
|
||||
"columnName": "deleted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "sensitive",
|
||||
"columnName": "sensitive",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "favorite",
|
||||
"columnName": "favorite",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '01175120dc4178de02503037e673b3ce')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,160 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
|
||||
*/
|
||||
package org.fcitx.fcitx5.android
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room.Room
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardDao
|
||||
import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardDatabase
|
||||
import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardEntry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class ClipboardDatabaseTest {
|
||||
|
||||
@get:Rule
|
||||
val migrationHelper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
ClipboardDatabase::class.java
|
||||
)
|
||||
|
||||
private lateinit var database: ClipboardDatabase
|
||||
private lateinit var dao: ClipboardDao
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
database = Room.inMemoryDatabaseBuilder(
|
||||
InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
ClipboardDatabase::class.java
|
||||
).build()
|
||||
dao = database.clipboardDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanup() {
|
||||
database.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migration4To5PreservesEntriesAndDefaultsFavoriteToFalse() {
|
||||
val name = "clipboard-migration-test"
|
||||
migrationHelper.createDatabase(name, 4).apply {
|
||||
execSQL(
|
||||
"INSERT INTO clipboard (id, text, pinned, timestamp, type, deleted, sensitive) " +
|
||||
"VALUES (1, 'kept', 1, 123, 'text/plain', 0, 1)"
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
migrationHelper.runMigrationsAndValidate(name, 5, true).apply {
|
||||
query("SELECT * FROM clipboard WHERE id=1").use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals("kept", cursor.getString(cursor.getColumnIndexOrThrow("text")))
|
||||
assertEquals(1, cursor.getInt(cursor.getColumnIndexOrThrow("pinned")))
|
||||
assertEquals(0, cursor.getInt(cursor.getColumnIndexOrThrow("favorite")))
|
||||
}
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun favoritesAreFilteredAndKeepPinnedThenTimestampOrdering() = runBlocking {
|
||||
insert(text = "old favorite", timestamp = 10, favorite = true)
|
||||
insert(text = "new favorite", timestamp = 30, favorite = true)
|
||||
insert(text = "pinned favorite", timestamp = 20, pinned = true, favorite = true)
|
||||
insert(text = "not favorite", timestamp = 40)
|
||||
|
||||
val result = dao.favoriteEntries().load(
|
||||
PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)
|
||||
)
|
||||
assertTrue(result is PagingSource.LoadResult.Page)
|
||||
val entries = result as PagingSource.LoadResult.Page<Int, ClipboardEntry>
|
||||
|
||||
assertEquals(
|
||||
listOf("pinned favorite", "new favorite", "old favorite"),
|
||||
entries.data.map { it.text }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun automaticDeletionTargetsOnlyUnprotectedEntries() = runBlocking {
|
||||
val deletableId = insert(text = "deletable", timestamp = 10)
|
||||
insert(text = "pinned", timestamp = 20, pinned = true)
|
||||
insert(text = "favorite", timestamp = 30, favorite = true)
|
||||
insert(text = "both", timestamp = 40, pinned = true, favorite = true)
|
||||
|
||||
assertTrue(dao.haveDeletable())
|
||||
assertArrayEquals(intArrayOf(deletableId), dao.findDeletableIds())
|
||||
|
||||
dao.markUnprotectedAsDeletedEarlierThan(Long.MAX_VALUE)
|
||||
|
||||
assertNull(dao.get(deletableId))
|
||||
assertEquals(3, dao.itemCount())
|
||||
assertFalse(dao.haveDeletable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun favoriteCanStillBeDeletedAndRestoredExplicitly() = runBlocking {
|
||||
val id = insert(text = "favorite", timestamp = 10, favorite = true)
|
||||
|
||||
dao.markAsDeleted(id)
|
||||
assertNull(dao.get(id))
|
||||
|
||||
dao.undoDelete(id)
|
||||
assertTrue(dao.get(id)?.favorite == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun favoriteStatusCanBeToggled() = runBlocking {
|
||||
val id = insert(text = "entry", timestamp = 10)
|
||||
|
||||
dao.updateFavoriteStatus(id, true)
|
||||
assertTrue(dao.get(id)?.favorite == true)
|
||||
|
||||
dao.updateFavoriteStatus(id, false)
|
||||
assertFalse(dao.get(id)?.favorite == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updatingTimestampPreservesPinnedAndFavoriteState() = runBlocking {
|
||||
val id = insert(
|
||||
text = "duplicate",
|
||||
timestamp = 1,
|
||||
pinned = true,
|
||||
favorite = true
|
||||
)
|
||||
|
||||
dao.updateTime(id, 99)
|
||||
|
||||
val updated = dao.get(id)!!
|
||||
assertEquals(99, updated.timestamp)
|
||||
assertTrue(updated.pinned)
|
||||
assertTrue(updated.favorite)
|
||||
}
|
||||
|
||||
private suspend fun insert(
|
||||
text: String,
|
||||
timestamp: Long,
|
||||
pinned: Boolean = false,
|
||||
favorite: Boolean = false
|
||||
): Int = dao.insert(
|
||||
ClipboardEntry(
|
||||
text = text,
|
||||
pinned = pinned,
|
||||
timestamp = timestamp,
|
||||
favorite = favorite
|
||||
)
|
||||
).toInt()
|
||||
}
|
||||
10
app/src/androidTest/res/mipmap/ic_launcher_debug.xml
Normal file
10
app/src/androidTest/res/mipmap/ic_launcher_debug.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#6750A4"
|
||||
android:pathData="M4,4h16v16h-16z" />
|
||||
</vector>
|
||||
10
app/src/androidTest/res/mipmap/ic_launcher_round_debug.xml
Normal file
10
app/src/androidTest/res/mipmap/ic_launcher_round_debug.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#6750A4"
|
||||
android:pathData="M12,2a10,10 0,1 0,0 20a10,10 0,1 0,0 -20" />
|
||||
</vector>
|
||||
5
app/src/androidTest/res/values/strings.xml
Normal file
5
app/src/androidTest/res/values/strings.xml
Normal file
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Debug build type aliases are also generated for the instrumentation APK. -->
|
||||
<string name="app_name_debug" translatable="false">Fcitx5 (Debug Test)</string>
|
||||
</resources>
|
||||
@ -0,0 +1,10 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
|
||||
*/
|
||||
package org.fcitx.fcitx5.android.data.clipboard
|
||||
|
||||
enum class ClipboardEntryFilter {
|
||||
All,
|
||||
Favorites
|
||||
}
|
||||
@ -100,14 +100,21 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
|
||||
|
||||
suspend fun get(id: Int) = clbDao.get(id)
|
||||
|
||||
suspend fun haveUnpinned() = clbDao.haveUnpinned()
|
||||
suspend fun haveDeletable() = clbDao.haveDeletable()
|
||||
|
||||
fun allEntries() = clbDao.allEntries()
|
||||
fun entries(filter: ClipboardEntryFilter) = when (filter) {
|
||||
ClipboardEntryFilter.All -> clbDao.allEntries()
|
||||
ClipboardEntryFilter.Favorites -> clbDao.favoriteEntries()
|
||||
}
|
||||
|
||||
suspend fun pin(id: Int) = clbDao.updatePinStatus(id, true)
|
||||
|
||||
suspend fun unpin(id: Int) = clbDao.updatePinStatus(id, false)
|
||||
|
||||
suspend fun favorite(id: Int) = clbDao.updateFavoriteStatus(id, true)
|
||||
|
||||
suspend fun unfavorite(id: Int) = clbDao.updateFavoriteStatus(id, false)
|
||||
|
||||
suspend fun updateText(id: Int, text: String) {
|
||||
lastEntry?.let {
|
||||
if (id == it.id) updateLastEntry(it.copy(text = text))
|
||||
@ -120,12 +127,8 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
|
||||
updateItemCount()
|
||||
}
|
||||
|
||||
suspend fun deleteAll(skipPinned: Boolean = true): IntArray {
|
||||
val ids = if (skipPinned) {
|
||||
clbDao.findUnpinnedIds()
|
||||
} else {
|
||||
clbDao.findAllIds()
|
||||
}
|
||||
suspend fun deleteAll(): IntArray {
|
||||
val ids = clbDao.findDeletableIds()
|
||||
clbDao.markAsDeleted(*ids)
|
||||
updateItemCount()
|
||||
return ids
|
||||
@ -195,15 +198,15 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener,
|
||||
|
||||
private suspend fun removeOutdated() {
|
||||
val limit = limitPref.getValue()
|
||||
val unpinned = clbDao.getAllUnpinned()
|
||||
if (unpinned.size > limit) {
|
||||
val unprotected = clbDao.getAllUnprotected()
|
||||
if (unprotected.size > limit) {
|
||||
// the last one we will keep
|
||||
val last = unpinned
|
||||
.sortedBy { it.id }
|
||||
.getOrNull(unpinned.size - limit)
|
||||
// delete all unpinned before that, or delete all when limit <= 0
|
||||
clbDao.markUnpinnedAsDeletedEarlierThan(last?.timestamp ?: System.currentTimeMillis())
|
||||
val last = unprotected
|
||||
.sortedBy { it.timestamp }
|
||||
.getOrNull(unprotected.size - limit)
|
||||
// delete all unprotected before that, or delete all when limit <= 0
|
||||
clbDao.markUnprotectedAsDeletedEarlierThan(last?.timestamp ?: System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,9 @@ interface ClipboardDao {
|
||||
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET pinned=:pinned WHERE id=:id")
|
||||
suspend fun updatePinStatus(id: Int, pinned: Boolean)
|
||||
|
||||
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET favorite=:favorite WHERE id=:id")
|
||||
suspend fun updateFavoriteStatus(id: Int, favorite: Boolean)
|
||||
|
||||
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET text=:text WHERE id=:id")
|
||||
suspend fun updateText(id: Int, text: String)
|
||||
|
||||
@ -32,33 +35,33 @@ interface ClipboardDao {
|
||||
@Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE rowId=:rowId AND deleted=0 LIMIT 1")
|
||||
suspend fun get(rowId: Long): ClipboardEntry?
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM ${ClipboardEntry.TABLE_NAME} WHERE pinned=0 AND deleted=0)")
|
||||
suspend fun haveUnpinned(): Boolean
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM ${ClipboardEntry.TABLE_NAME} WHERE pinned=0 AND favorite=0 AND deleted=0)")
|
||||
suspend fun haveDeletable(): Boolean
|
||||
|
||||
@Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE pinned=0 AND deleted=0")
|
||||
suspend fun getAllUnpinned(): List<ClipboardEntry>
|
||||
@Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE pinned=0 AND favorite=0 AND deleted=0")
|
||||
suspend fun getAllUnprotected(): List<ClipboardEntry>
|
||||
|
||||
@Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE deleted=0 ORDER BY pinned DESC, timestamp DESC")
|
||||
fun allEntries(): PagingSource<Int, ClipboardEntry>
|
||||
|
||||
@Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE favorite=1 AND deleted=0 ORDER BY pinned DESC, timestamp DESC")
|
||||
fun favoriteEntries(): PagingSource<Int, ClipboardEntry>
|
||||
|
||||
@Query("SELECT * FROM ${ClipboardEntry.TABLE_NAME} WHERE text=:text AND sensitive=:sensitive AND deleted=0 LIMIT 1")
|
||||
suspend fun find(text: String, sensitive: Boolean = false): ClipboardEntry?
|
||||
|
||||
@Query("SELECT id FROM ${ClipboardEntry.TABLE_NAME} WHERE deleted=0")
|
||||
suspend fun findAllIds(): IntArray
|
||||
|
||||
@Query("SELECT id FROM ${ClipboardEntry.TABLE_NAME} WHERE pinned=0 AND deleted=0")
|
||||
suspend fun findUnpinnedIds(): IntArray
|
||||
@Query("SELECT id FROM ${ClipboardEntry.TABLE_NAME} WHERE pinned=0 AND favorite=0 AND deleted=0")
|
||||
suspend fun findDeletableIds(): IntArray
|
||||
|
||||
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET deleted=1 WHERE id in (:ids)")
|
||||
suspend fun markAsDeleted(vararg ids: Int)
|
||||
|
||||
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET DELETED=1 WHERE timestamp<:timestamp AND pinned=0 AND deleted=0")
|
||||
suspend fun markUnpinnedAsDeletedEarlierThan(timestamp: Long)
|
||||
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET deleted=1 WHERE timestamp<:timestamp AND pinned=0 AND favorite=0 AND deleted=0")
|
||||
suspend fun markUnprotectedAsDeletedEarlierThan(timestamp: Long)
|
||||
|
||||
@Query("UPDATE ${ClipboardEntry.TABLE_NAME} SET deleted=0 WHERE id in (:ids) AND deleted=1")
|
||||
suspend fun undoDelete(vararg ids: Int)
|
||||
|
||||
@Query("DELETE FROM ${ClipboardEntry.TABLE_NAME} WHERE deleted=1")
|
||||
suspend fun realDelete()
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,13 +10,14 @@ import androidx.room.RoomDatabase
|
||||
|
||||
@Database(
|
||||
entities = [ClipboardEntry::class],
|
||||
version = 4,
|
||||
version = 5,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 1, to = 2),
|
||||
AutoMigration(from = 2, to = 3),
|
||||
AutoMigration(from = 3, to = 4)
|
||||
AutoMigration(from = 3, to = 4),
|
||||
AutoMigration(from = 4, to = 5)
|
||||
]
|
||||
)
|
||||
abstract class ClipboardDatabase : RoomDatabase() {
|
||||
abstract fun clipboardDao(): ClipboardDao
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,7 +25,9 @@ data class ClipboardEntry(
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
val deleted: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
val sensitive: Boolean = false
|
||||
val sensitive: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
val favorite: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
const val BULLET = "•"
|
||||
@ -59,4 +61,4 @@ data class ClipboardEntry(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -463,7 +463,9 @@ class KawaiiBarComponent : UniqueViewComponent<KawaiiBarComponent, FrameLayout>(
|
||||
when (window) {
|
||||
is InputWindow.ExtendedInputWindow<*> -> {
|
||||
titleUi.setTitle(window.title)
|
||||
window.onCreateBarExtension()?.let { titleUi.addExtension(it, window.showTitle) }
|
||||
window.onCreateBarExtension()?.let {
|
||||
titleUi.addExtension(it, window.showTitle, window.showReturnButton)
|
||||
}
|
||||
titleUi.setReturnButtonOnClickListener {
|
||||
windowManager.attachWindow(KeyboardWindow)
|
||||
}
|
||||
|
||||
@ -65,11 +65,11 @@ class TitleUi(override val ctx: Context, theme: Theme) : Ui {
|
||||
titleText.text = title
|
||||
}
|
||||
|
||||
fun addExtension(view: View, showTitle: Boolean) {
|
||||
fun addExtension(view: View, showTitle: Boolean, showReturnButton: Boolean = showTitle) {
|
||||
if (extension != null) {
|
||||
throw IllegalStateException("TitleBar extension is already present")
|
||||
}
|
||||
backButton.isVisible = showTitle
|
||||
backButton.isVisible = showReturnButton
|
||||
titleText.isVisible = showTitle
|
||||
extension = view
|
||||
root.run {
|
||||
@ -77,6 +77,9 @@ class TitleUi(override val ctx: Context, theme: Theme) : Ui {
|
||||
centerVertically()
|
||||
if (showTitle) {
|
||||
endOfParent(dp(5))
|
||||
} else if (showReturnButton) {
|
||||
after(backButton)
|
||||
endOfParent(dp(5))
|
||||
} else {
|
||||
centerHorizontally()
|
||||
}
|
||||
|
||||
@ -91,7 +91,11 @@ abstract class ClipboardAdapter(
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val entry = getItem(position) ?: return
|
||||
with(holder.entryUi) {
|
||||
setEntry(excerptText(entry.text, entry.sensitive && maskSensitive), entry.pinned)
|
||||
setEntry(
|
||||
excerptText(entry.text, entry.sensitive && maskSensitive),
|
||||
entry.pinned,
|
||||
entry.favorite
|
||||
)
|
||||
root.setOnClickListener {
|
||||
onPaste(entry)
|
||||
}
|
||||
@ -108,6 +112,15 @@ abstract class ClipboardAdapter(
|
||||
onPin(entry.id)
|
||||
}
|
||||
}
|
||||
if (entry.favorite) {
|
||||
menu.item(R.string.unfavorite, R.drawable.ic_outline_star_24, iconTint) {
|
||||
onUnfavorite(entry.id)
|
||||
}
|
||||
} else {
|
||||
menu.item(R.string.favorite, R.drawable.ic_baseline_star_24, iconTint) {
|
||||
onFavorite(entry.id)
|
||||
}
|
||||
}
|
||||
menu.item(R.string.edit, R.drawable.ic_baseline_edit_24, iconTint) {
|
||||
onEdit(entry.id)
|
||||
}
|
||||
@ -144,10 +157,14 @@ abstract class ClipboardAdapter(
|
||||
|
||||
abstract fun onUnpin(id: Int)
|
||||
|
||||
abstract fun onFavorite(id: Int)
|
||||
|
||||
abstract fun onUnfavorite(id: Int)
|
||||
|
||||
abstract fun onEdit(id: Int)
|
||||
|
||||
abstract fun onShare(entry: ClipboardEntry)
|
||||
|
||||
abstract fun onDelete(id: Int)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView
|
||||
import splitties.dimensions.dp
|
||||
import splitties.resources.drawable
|
||||
import splitties.views.dsl.constraintlayout.bottomOfParent
|
||||
import splitties.views.dsl.constraintlayout.before
|
||||
import splitties.views.dsl.constraintlayout.centerVertically
|
||||
import splitties.views.dsl.constraintlayout.constraintLayout
|
||||
import splitties.views.dsl.constraintlayout.endOfParent
|
||||
@ -49,11 +50,17 @@ class ClipboardEntryUi(override val ctx: Context, private val theme: Theme, radi
|
||||
}
|
||||
}
|
||||
|
||||
val favorite = imageView()
|
||||
|
||||
val layout = constraintLayout {
|
||||
add(textView, lParams(matchParent, wrapContent) {
|
||||
centerVertically()
|
||||
})
|
||||
add(pin, lParams(dp(12), dp(12)) {
|
||||
bottomOfParent(dp(2))
|
||||
before(favorite, dp(2))
|
||||
})
|
||||
add(favorite, lParams(dp(12), dp(12)) {
|
||||
bottomOfParent(dp(2))
|
||||
endOfParent(dp(2))
|
||||
})
|
||||
@ -76,8 +83,14 @@ class ClipboardEntryUi(override val ctx: Context, private val theme: Theme, radi
|
||||
add(layout, lParams(matchParent, matchParent))
|
||||
}
|
||||
|
||||
fun setEntry(text: String, pinned: Boolean) {
|
||||
fun setEntry(text: String, pinned: Boolean, isFavorite: Boolean) {
|
||||
textView.text = text
|
||||
pin.visibility = if (pinned) View.VISIBLE else View.GONE
|
||||
favorite.imageDrawable = ctx.drawable(
|
||||
if (isFavorite) R.drawable.ic_baseline_star_24 else R.drawable.ic_outline_star_24
|
||||
)!!.apply {
|
||||
setTint(if (isFavorite) theme.accentKeyBackgroundColor else theme.altKeyTextColor)
|
||||
alpha = if (isFavorite) 0xff else 0x4d
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,4 +80,31 @@ sealed class ClipboardInstructionUi(override val ctx: Context, protected val the
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class FavoritesEmpty(ctx: Context, theme: Theme) : ClipboardInstructionUi(ctx, theme) {
|
||||
|
||||
private val icon = imageView {
|
||||
imageDrawable = drawable(R.drawable.ic_outline_star_24)!!.apply {
|
||||
setTint(theme.altKeyTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
private val instructionText = textView {
|
||||
setText(R.string.instruction_favorite)
|
||||
setTextColor(theme.keyTextColor)
|
||||
}
|
||||
|
||||
override val root = constraintLayout {
|
||||
add(icon, lParams(dp(90), dp(90)) {
|
||||
topOfParent(dp(24))
|
||||
startOfParent()
|
||||
endOfParent()
|
||||
})
|
||||
add(instructionText, lParams(wrapContent, wrapContent) {
|
||||
below(icon, dp(16))
|
||||
startOfParent()
|
||||
endOfParent()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
|
||||
*/
|
||||
package org.fcitx.fcitx5.android.input.clipboard
|
||||
|
||||
enum class ClipboardPanelSection {
|
||||
Clipboard,
|
||||
Favorites
|
||||
}
|
||||
@ -1,56 +1,26 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
* SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors
|
||||
* SPDX-FileCopyrightText: Copyright 2021-2026 Fcitx5 for Android Contributors
|
||||
*/
|
||||
package org.fcitx.fcitx5.android.input.clipboard
|
||||
|
||||
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.BooleanKey.ClipboardDbEmpty
|
||||
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.BooleanKey.ClipboardListeningEnabled
|
||||
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.State.AddMore
|
||||
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.State.EnableListening
|
||||
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.State.Normal
|
||||
import org.fcitx.fcitx5.android.utils.BuildTransitionEvent
|
||||
import org.fcitx.fcitx5.android.utils.EventStateMachine
|
||||
import org.fcitx.fcitx5.android.utils.TransitionBuildBlock
|
||||
|
||||
object ClipboardStateMachine {
|
||||
|
||||
enum class State {
|
||||
Normal, AddMore, EnableListening
|
||||
Normal,
|
||||
AddMore,
|
||||
NoFavorites,
|
||||
EnableListening
|
||||
}
|
||||
|
||||
enum class BooleanKey : EventStateMachine.BooleanStateKey {
|
||||
ClipboardDbEmpty,
|
||||
ClipboardListeningEnabled
|
||||
fun resolve(
|
||||
listening: Boolean,
|
||||
section: ClipboardPanelSection,
|
||||
visibleEntriesEmpty: Boolean
|
||||
): State = when {
|
||||
!listening -> State.EnableListening
|
||||
!visibleEntriesEmpty -> State.Normal
|
||||
section == ClipboardPanelSection.Favorites -> State.NoFavorites
|
||||
else -> State.AddMore
|
||||
}
|
||||
|
||||
enum class TransitionEvent(val builder: TransitionBuildBlock<State, BooleanKey>) :
|
||||
EventStateMachine.TransitionEvent<State, BooleanKey> by BuildTransitionEvent(builder) {
|
||||
ClipboardDbUpdated({
|
||||
from(Normal) transitTo AddMore on (ClipboardDbEmpty to true)
|
||||
from(AddMore) transitTo Normal on (ClipboardDbEmpty to false)
|
||||
}),
|
||||
ClipboardListeningUpdated({
|
||||
from(Normal) transitTo EnableListening on (ClipboardListeningEnabled to false)
|
||||
from(EnableListening) transitTo Normal onF {
|
||||
it(ClipboardListeningEnabled) == true && it(ClipboardDbEmpty) == false
|
||||
}
|
||||
from(EnableListening) transitTo AddMore onF {
|
||||
it(ClipboardListeningEnabled) == true && it(ClipboardDbEmpty) == true
|
||||
}
|
||||
from(AddMore) transitTo EnableListening on (ClipboardListeningEnabled to false)
|
||||
})
|
||||
}
|
||||
|
||||
fun new(initial: State, empty: Boolean, listening: Boolean, block: (State) -> Unit) =
|
||||
EventStateMachine<State, TransitionEvent, BooleanKey>(
|
||||
initialState = initial,
|
||||
externalBooleanStates = mutableMapOf(
|
||||
ClipboardDbEmpty to empty,
|
||||
ClipboardListeningEnabled to listening
|
||||
)
|
||||
).apply {
|
||||
onNewStateListener = block
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,111 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
|
||||
*/
|
||||
package org.fcitx.fcitx5.android.input.clipboard
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Typeface
|
||||
import android.view.View
|
||||
import org.fcitx.fcitx5.android.R
|
||||
import org.fcitx.fcitx5.android.data.theme.Theme
|
||||
import org.fcitx.fcitx5.android.data.theme.ThemeManager
|
||||
import org.fcitx.fcitx5.android.input.bar.ui.ToolButton
|
||||
import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView
|
||||
import org.fcitx.fcitx5.android.utils.alpha
|
||||
import org.fcitx.fcitx5.android.utils.pressHighlightDrawable
|
||||
import org.fcitx.fcitx5.android.utils.rippleDrawable
|
||||
import splitties.dimensions.dp
|
||||
import splitties.views.dsl.constraintlayout.after
|
||||
import splitties.views.dsl.constraintlayout.before
|
||||
import splitties.views.dsl.constraintlayout.bottomOfParent
|
||||
import splitties.views.dsl.constraintlayout.centerVertically
|
||||
import splitties.views.dsl.constraintlayout.constraintLayout
|
||||
import splitties.views.dsl.constraintlayout.endOfParent
|
||||
import splitties.views.dsl.constraintlayout.lParams
|
||||
import splitties.views.dsl.constraintlayout.startOfParent
|
||||
import splitties.views.dsl.constraintlayout.topOfParent
|
||||
import splitties.views.dsl.core.Ui
|
||||
import splitties.views.dsl.core.add
|
||||
import splitties.views.dsl.core.lParams
|
||||
import splitties.views.dsl.core.matchParent
|
||||
import splitties.views.dsl.core.textView
|
||||
import splitties.views.dsl.core.view
|
||||
import splitties.views.gravityCenter
|
||||
|
||||
class ClipboardTopBarUi(override val ctx: Context, private val theme: Theme) : Ui {
|
||||
|
||||
private val keyRipple by ThemeManager.prefs.keyRippleEffect
|
||||
|
||||
inner class Tab(private val section: ClipboardPanelSection, textRes: Int) : Ui {
|
||||
override val ctx = this@ClipboardTopBarUi.ctx
|
||||
|
||||
private val label = textView {
|
||||
setText(textRes)
|
||||
textSize = 15f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
gravity = gravityCenter
|
||||
}
|
||||
|
||||
override val root = view(::CustomGestureView) {
|
||||
isClickable = true
|
||||
contentDescription = label.text
|
||||
add(label, lParams(matchParent, matchParent))
|
||||
if (keyRipple) {
|
||||
background = rippleDrawable(theme.keyPressHighlightColor)
|
||||
} else {
|
||||
foreground = pressHighlightDrawable(theme.keyPressHighlightColor)
|
||||
}
|
||||
setOnClickListener { onSectionSelected?.invoke(section) }
|
||||
}
|
||||
|
||||
fun setActive(active: Boolean) {
|
||||
label.setTextColor(theme.keyTextColor.alpha(if (active) 1f else 0.5f))
|
||||
}
|
||||
}
|
||||
|
||||
private val clipboardTab = Tab(ClipboardPanelSection.Clipboard, R.string.clipboard)
|
||||
private val favoritesTab = Tab(ClipboardPanelSection.Favorites, R.string.favorites)
|
||||
|
||||
val deleteAllButton = ToolButton(ctx, R.drawable.ic_baseline_delete_sweep_24, theme).apply {
|
||||
contentDescription = ctx.getString(R.string.delete_all)
|
||||
}
|
||||
|
||||
private var onSectionSelected: ((ClipboardPanelSection) -> Unit)? = null
|
||||
|
||||
override val root = constraintLayout {
|
||||
add(clipboardTab.root, lParams {
|
||||
topOfParent()
|
||||
startOfParent()
|
||||
bottomOfParent()
|
||||
before(favoritesTab.root)
|
||||
})
|
||||
add(favoritesTab.root, lParams {
|
||||
topOfParent()
|
||||
after(clipboardTab.root)
|
||||
bottomOfParent()
|
||||
before(deleteAllButton)
|
||||
})
|
||||
add(deleteAllButton, lParams(dp(40), dp(40)) {
|
||||
centerVertically()
|
||||
endOfParent()
|
||||
})
|
||||
}
|
||||
|
||||
init {
|
||||
setActiveSection(ClipboardPanelSection.Clipboard)
|
||||
}
|
||||
|
||||
fun setOnSectionSelectedListener(listener: (ClipboardPanelSection) -> Unit) {
|
||||
onSectionSelected = listener
|
||||
}
|
||||
|
||||
fun setActiveSection(section: ClipboardPanelSection) {
|
||||
clipboardTab.setActive(section == ClipboardPanelSection.Clipboard)
|
||||
favoritesTab.setActive(section == ClipboardPanelSection.Favorites)
|
||||
}
|
||||
|
||||
fun setDeleteButtonShown(shown: Boolean) {
|
||||
deleteAllButton.visibility = if (shown) View.VISIBLE else View.INVISIBLE
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,6 @@
|
||||
package org.fcitx.fcitx5.android.input.clipboard
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.widget.ViewAnimator
|
||||
import androidx.transition.Fade
|
||||
import androidx.transition.TransitionManager
|
||||
@ -13,14 +12,12 @@ import org.fcitx.fcitx5.android.R
|
||||
import org.fcitx.fcitx5.android.data.prefs.AppPrefs
|
||||
import org.fcitx.fcitx5.android.data.theme.Theme
|
||||
import org.fcitx.fcitx5.android.data.theme.ThemeManager
|
||||
import org.fcitx.fcitx5.android.input.bar.ui.ToolButton
|
||||
import splitties.dimensions.dp
|
||||
import splitties.views.backgroundColor
|
||||
import splitties.views.dsl.coordinatorlayout.coordinatorLayout
|
||||
import splitties.views.dsl.coordinatorlayout.defaultLParams
|
||||
import splitties.views.dsl.core.Ui
|
||||
import splitties.views.dsl.core.add
|
||||
import splitties.views.dsl.core.horizontalLayout
|
||||
import splitties.views.dsl.core.lParams
|
||||
import splitties.views.dsl.core.matchParent
|
||||
import splitties.views.dsl.core.view
|
||||
@ -37,10 +34,13 @@ class ClipboardUi(override val ctx: Context, private val theme: Theme) : Ui {
|
||||
|
||||
val emptyUi = ClipboardInstructionUi.Empty(ctx, theme)
|
||||
|
||||
val favoritesEmptyUi = ClipboardInstructionUi.FavoritesEmpty(ctx, theme)
|
||||
|
||||
val viewAnimator = view(::ViewAnimator) {
|
||||
add(recyclerView, lParams(matchParent, matchParent))
|
||||
add(emptyUi.root, lParams(matchParent, matchParent))
|
||||
add(enableUi.root, lParams(matchParent, matchParent))
|
||||
add(favoritesEmptyUi.root, lParams(matchParent, matchParent))
|
||||
}
|
||||
|
||||
private val keyBorder by ThemeManager.prefs.keyBorder
|
||||
@ -53,35 +53,28 @@ class ClipboardUi(override val ctx: Context, private val theme: Theme) : Ui {
|
||||
add(viewAnimator, defaultLParams(matchParent, matchParent))
|
||||
}
|
||||
|
||||
val deleteAllButton = ToolButton(ctx, R.drawable.ic_baseline_delete_sweep_24, theme).apply {
|
||||
contentDescription = ctx.getString(R.string.delete_all)
|
||||
}
|
||||
val topBar = ClipboardTopBarUi(ctx, theme)
|
||||
|
||||
val extension = horizontalLayout {
|
||||
add(deleteAllButton, lParams(dp(40), dp(40)))
|
||||
}
|
||||
val extension = topBar.root
|
||||
|
||||
private fun setDeleteButtonShown(enabled: Boolean) {
|
||||
deleteAllButton.visibility = if (enabled) View.VISIBLE else View.INVISIBLE
|
||||
}
|
||||
|
||||
fun switchUiByState(state: ClipboardStateMachine.State) {
|
||||
fun switchUiByState(state: ClipboardStateMachine.State, showDeleteButton: Boolean) {
|
||||
Timber.d("Switch clipboard to $state")
|
||||
if (!disableAnimation)
|
||||
TransitionManager.beginDelayedTransition(root, Fade().apply { duration = 100L })
|
||||
when (state) {
|
||||
ClipboardStateMachine.State.Normal -> {
|
||||
viewAnimator.displayedChild = 0
|
||||
setDeleteButtonShown(true)
|
||||
}
|
||||
ClipboardStateMachine.State.AddMore -> {
|
||||
viewAnimator.displayedChild = 1
|
||||
setDeleteButtonShown(false)
|
||||
}
|
||||
ClipboardStateMachine.State.EnableListening -> {
|
||||
viewAnimator.displayedChild = 2
|
||||
setDeleteButtonShown(false)
|
||||
}
|
||||
ClipboardStateMachine.State.NoFavorites -> {
|
||||
viewAnimator.displayedChild = 3
|
||||
}
|
||||
}
|
||||
topBar.setDeleteButtonShown(showDeleteButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,8 @@ import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.color
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.paging.CombinedLoadStates
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
@ -25,28 +27,22 @@ import com.google.android.material.snackbar.BaseTransientBottomBar.BaseCallback
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.snackbar.SnackbarContentLayout
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.fcitx.fcitx5.android.R
|
||||
import org.fcitx.fcitx5.android.data.clipboard.ClipboardManager
|
||||
import org.fcitx.fcitx5.android.data.clipboard.ClipboardEntryFilter
|
||||
import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardEntry
|
||||
import org.fcitx.fcitx5.android.data.prefs.AppPrefs
|
||||
import org.fcitx.fcitx5.android.data.prefs.ManagedPreference
|
||||
import org.fcitx.fcitx5.android.data.theme.ThemeManager
|
||||
import org.fcitx.fcitx5.android.input.FcitxInputMethodService
|
||||
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.BooleanKey.ClipboardDbEmpty
|
||||
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.BooleanKey.ClipboardListeningEnabled
|
||||
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.State.AddMore
|
||||
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.State.EnableListening
|
||||
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.State.Normal
|
||||
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.TransitionEvent.ClipboardDbUpdated
|
||||
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.TransitionEvent.ClipboardListeningUpdated
|
||||
import org.fcitx.fcitx5.android.input.dependency.inputMethodService
|
||||
import org.fcitx.fcitx5.android.input.dependency.theme
|
||||
import org.fcitx.fcitx5.android.input.keyboard.KeyboardWindow
|
||||
import org.fcitx.fcitx5.android.input.wm.InputWindow
|
||||
import org.fcitx.fcitx5.android.input.wm.InputWindowManager
|
||||
import org.fcitx.fcitx5.android.utils.AppUtil
|
||||
import org.fcitx.fcitx5.android.utils.EventStateMachine
|
||||
import org.fcitx.fcitx5.android.utils.item
|
||||
import org.mechdancer.dependency.manager.must
|
||||
import splitties.dimensions.dp
|
||||
@ -64,13 +60,9 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
|
||||
}
|
||||
private var snackbarInstance: Snackbar? = null
|
||||
|
||||
private lateinit var stateMachine: EventStateMachine<ClipboardStateMachine.State, ClipboardStateMachine.TransitionEvent, ClipboardStateMachine.BooleanKey>
|
||||
|
||||
@Keep
|
||||
private val clipboardEnabledListener = ManagedPreference.OnChangeListener<Boolean> { _, it ->
|
||||
stateMachine.push(
|
||||
ClipboardListeningUpdated, ClipboardListeningEnabled to it
|
||||
)
|
||||
private val clipboardEnabledListener = ManagedPreference.OnChangeListener<Boolean> { _, _ ->
|
||||
renderUi()
|
||||
}
|
||||
|
||||
private val prefs = AppPrefs.getInstance().clipboard
|
||||
@ -81,11 +73,12 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
|
||||
|
||||
private val clipboardEntryRadius by ThemeManager.prefs.clipboardEntryRadius
|
||||
|
||||
private val clipboardEntriesPager by lazy {
|
||||
Pager(PagingConfig(pageSize = 16)) { ClipboardManager.allEntries() }
|
||||
}
|
||||
private var adapterSubmitJob: Job? = null
|
||||
|
||||
private var selectedSection = ClipboardPanelSection.Clipboard
|
||||
private var visibleEntriesEmpty = true
|
||||
private var deleteAvailable = false
|
||||
|
||||
private val adapter: ClipboardAdapter by lazy {
|
||||
object : ClipboardAdapter(
|
||||
theme,
|
||||
@ -100,6 +93,14 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
|
||||
service.lifecycleScope.launch { ClipboardManager.unpin(id) }
|
||||
}
|
||||
|
||||
override fun onFavorite(id: Int) {
|
||||
service.lifecycleScope.launch { ClipboardManager.favorite(id) }
|
||||
}
|
||||
|
||||
override fun onUnfavorite(id: Int) {
|
||||
service.lifecycleScope.launch { ClipboardManager.unfavorite(id) }
|
||||
}
|
||||
|
||||
override fun onEdit(id: Int) {
|
||||
AppUtil.launchClipboardEdit(context, id)
|
||||
}
|
||||
@ -162,10 +163,11 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
|
||||
enableUi.enableButton.setOnClickListener {
|
||||
clipboardEnabledPref.setValue(true)
|
||||
}
|
||||
deleteAllButton.setOnClickListener {
|
||||
service.lifecycleScope.launch {
|
||||
promptDeleteAll(ClipboardManager.haveUnpinned())
|
||||
}
|
||||
topBar.setOnSectionSelectedListener {
|
||||
showSection(it)
|
||||
}
|
||||
topBar.deleteAllButton.setOnClickListener {
|
||||
promptDeleteAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -174,20 +176,20 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
|
||||
|
||||
private var promptMenu: PopupMenu? = null
|
||||
|
||||
private fun promptDeleteAll(skipPinned: Boolean) {
|
||||
private fun promptDeleteAll() {
|
||||
promptMenu?.dismiss()
|
||||
promptMenu = PopupMenu(context, ui.deleteAllButton).apply {
|
||||
promptMenu = PopupMenu(context, ui.topBar.deleteAllButton).apply {
|
||||
menu.add(buildSpannedString {
|
||||
bold {
|
||||
color(context.styledColor(android.R.attr.colorAccent)) {
|
||||
append(context.getString(if (skipPinned) R.string.delete_all_except_pinned else R.string.delete_all_pinned_items))
|
||||
append(context.getString(R.string.delete_all_except_pinned_and_favorites))
|
||||
}
|
||||
}
|
||||
}).isEnabled = false
|
||||
menu.add(android.R.string.cancel)
|
||||
menu.item(android.R.string.ok) {
|
||||
service.lifecycleScope.launch {
|
||||
val ids = ClipboardManager.deleteAll(skipPinned)
|
||||
val ids = ClipboardManager.deleteAll()
|
||||
showUndoSnackbar(*ids)
|
||||
}
|
||||
}
|
||||
@ -202,6 +204,7 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
private fun showUndoSnackbar(vararg id: Int) {
|
||||
if (id.isEmpty()) return
|
||||
id.forEach { pendingDeleteIds.add(it) }
|
||||
val str = context.resources.getString(R.string.num_items_deleted, pendingDeleteIds.size)
|
||||
snackbarInstance = Snackbar.make(snackbarCtx, ui.root, str, Snackbar.LENGTH_LONG)
|
||||
@ -250,35 +253,75 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttached() {
|
||||
val isEmpty = ClipboardManager.itemCount == 0
|
||||
val isListening = clipboardEnabledPref.getValue()
|
||||
val initialState = when {
|
||||
!isListening -> EnableListening
|
||||
isEmpty -> AddMore
|
||||
else -> Normal
|
||||
}
|
||||
stateMachine = ClipboardStateMachine.new(initialState, isEmpty, isListening) {
|
||||
ui.switchUiByState(it)
|
||||
}
|
||||
// manually switch to initial ui
|
||||
ui.switchUiByState(initialState)
|
||||
adapter.addLoadStateListener {
|
||||
val empty = it.append.endOfPaginationReached && adapter.itemCount < 1
|
||||
stateMachine.push(ClipboardDbUpdated, ClipboardDbEmpty to empty)
|
||||
private val adapterLoadStateListener: (CombinedLoadStates) -> Unit = {
|
||||
if (it.refresh is LoadState.NotLoading) {
|
||||
visibleEntriesEmpty = it.append.endOfPaginationReached && adapter.itemCount < 1
|
||||
refreshDeleteAvailability()
|
||||
renderUi()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSection(section: ClipboardPanelSection) {
|
||||
if (selectedSection == section && adapterSubmitJob?.isActive == true) return
|
||||
selectedSection = section
|
||||
visibleEntriesEmpty = false
|
||||
deleteAvailable = false
|
||||
ui.topBar.setActiveSection(section)
|
||||
renderUi()
|
||||
adapterSubmitJob?.cancel()
|
||||
adapterSubmitJob = service.lifecycleScope.launch {
|
||||
clipboardEntriesPager.flow.collect {
|
||||
adapter.submitData(it)
|
||||
val filter = when (section) {
|
||||
ClipboardPanelSection.Clipboard -> ClipboardEntryFilter.All
|
||||
ClipboardPanelSection.Favorites -> ClipboardEntryFilter.Favorites
|
||||
}
|
||||
Pager(PagingConfig(pageSize = 16)) { ClipboardManager.entries(filter) }
|
||||
.flow
|
||||
.collectLatest { adapter.submitData(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshDeleteAvailability() {
|
||||
val section = selectedSection
|
||||
service.lifecycleScope.launch {
|
||||
val available = ClipboardManager.haveDeletable()
|
||||
if (selectedSection == section) {
|
||||
deleteAvailable = available
|
||||
renderUi()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderUi() {
|
||||
val state = ClipboardStateMachine.resolve(
|
||||
clipboardEnabledPref.getValue(),
|
||||
selectedSection,
|
||||
visibleEntriesEmpty
|
||||
)
|
||||
ui.switchUiByState(
|
||||
state,
|
||||
state == ClipboardStateMachine.State.Normal &&
|
||||
selectedSection == ClipboardPanelSection.Clipboard &&
|
||||
deleteAvailable
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAttached() {
|
||||
selectedSection = ClipboardPanelSection.Clipboard
|
||||
visibleEntriesEmpty = ClipboardManager.itemCount == 0
|
||||
deleteAvailable = false
|
||||
ui.topBar.setActiveSection(selectedSection)
|
||||
adapter.addLoadStateListener(adapterLoadStateListener)
|
||||
renderUi()
|
||||
showSection(selectedSection)
|
||||
clipboardEnabledPref.registerOnChangeListener(clipboardEnabledListener)
|
||||
}
|
||||
|
||||
override fun onDetached() {
|
||||
clipboardEnabledPref.unregisterOnChangeListener(clipboardEnabledListener)
|
||||
adapter.removeLoadStateListener(adapterLoadStateListener)
|
||||
adapter.onDetached()
|
||||
adapterSubmitJob?.cancel()
|
||||
adapterSubmitJob = null
|
||||
promptMenu?.dismiss()
|
||||
snackbarInstance?.dismiss()
|
||||
}
|
||||
@ -287,5 +330,9 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
|
||||
context.getString(R.string.clipboard)
|
||||
}
|
||||
|
||||
override val showTitle = false
|
||||
|
||||
override val showReturnButton = true
|
||||
|
||||
override fun onCreateBarExtension(): View = ui.extension
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,6 +68,8 @@ sealed class InputWindow : Dependent {
|
||||
|
||||
open val showTitle: Boolean = true
|
||||
|
||||
open val showReturnButton: Boolean = showTitle
|
||||
|
||||
open val title: String = ""
|
||||
|
||||
open fun onCreateBarExtension(): View? {
|
||||
@ -83,4 +85,4 @@ sealed class InputWindow : Dependent {
|
||||
override fun toString(): String = javaClass.name
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
app/src/main/res/drawable/ic_baseline_star_24.xml
Normal file
14
app/src/main/res/drawable/ic_baseline_star_24.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ SPDX-License-Identifier: Apache-2.0
|
||||
~ SPDX-FileCopyrightText: 2026 Google LLC
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z" />
|
||||
</vector>
|
||||
14
app/src/main/res/drawable/ic_outline_star_24.xml
Normal file
14
app/src/main/res/drawable/ic_outline_star_24.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ SPDX-License-Identifier: Apache-2.0
|
||||
~ SPDX-FileCopyrightText: 2026 Google LLC
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4l-3.76,2.27 1,-4.28 -3.32,-2.88 4.38,-0.38L12,6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28z" />
|
||||
</vector>
|
||||
@ -59,6 +59,9 @@
|
||||
<string name="clear_clb_db">清空剪贴板数据库</string>
|
||||
<string name="pin">置顶</string>
|
||||
<string name="unpin">取消置顶</string>
|
||||
<string name="favorite">收藏</string>
|
||||
<string name="unfavorite">取消收藏</string>
|
||||
<string name="favorites">收藏</string>
|
||||
<string name="delete">删除</string>
|
||||
<string name="clipboard">剪贴板</string>
|
||||
<string name="keyboard_height">键盘高度</string>
|
||||
@ -80,6 +83,7 @@
|
||||
<string name="text_editing">文本编辑</string>
|
||||
<string name="instruction_enable_clipboard_listening">要启用剪贴板管理,请在设置中开启“记录剪贴板历史”。</string>
|
||||
<string name="instruction_copy">试着复制些东西</string>
|
||||
<string name="instruction_favorite">长按剪贴板条目即可收藏</string>
|
||||
<string name="clear">清空</string>
|
||||
<string name="export">导出</string>
|
||||
<string name="app_crash">应用崩溃</string>
|
||||
@ -209,6 +213,7 @@
|
||||
<string name="horizontal_candidate_always_fill">总是填充宽度</string>
|
||||
<string name="inline_suggestions">显示内嵌自动填充建议</string>
|
||||
<string name="delete_all_except_pinned">要删除所有未置顶条目吗?</string>
|
||||
<string name="delete_all_except_pinned_and_favorites">要删除所有未置顶且未收藏的条目吗?</string>
|
||||
<string name="num_items_deleted">已删除 %1$d 项</string>
|
||||
<string name="delete_theme">删除主题</string>
|
||||
<string name="delete_theme_msg">确定要删除主题“%1$s”吗?</string>
|
||||
|
||||
@ -59,6 +59,9 @@
|
||||
<string name="clear_clb_db">清除剪貼簿資料庫</string>
|
||||
<string name="pin">釘選</string>
|
||||
<string name="unpin">取消釘選</string>
|
||||
<string name="favorite">收藏</string>
|
||||
<string name="unfavorite">取消收藏</string>
|
||||
<string name="favorites">收藏</string>
|
||||
<string name="delete">刪除</string>
|
||||
<string name="clipboard">剪貼簿</string>
|
||||
<string name="keyboard_height">鍵盤高度</string>
|
||||
@ -80,6 +83,7 @@
|
||||
<string name="text_editing">文字編輯</string>
|
||||
<string name="instruction_enable_clipboard_listening">要使用剪貼簿管理,請在設定中啟用監聽剪貼簿</string>
|
||||
<string name="instruction_copy">嘗試複製一些東西</string>
|
||||
<string name="instruction_favorite">長按剪貼簿項目即可收藏</string>
|
||||
<string name="clear">清除</string>
|
||||
<string name="export">匯出</string>
|
||||
<string name="app_crash">應用程式當機</string>
|
||||
@ -209,6 +213,7 @@
|
||||
<string name="horizontal_candidate_always_fill">總是填充寬度</string>
|
||||
<string name="inline_suggestions">顯示內嵌自動填充建議</string>
|
||||
<string name="delete_all_except_pinned">要刪除所有未置頂條目嗎?</string>
|
||||
<string name="delete_all_except_pinned_and_favorites">要刪除所有未釘選且未收藏的項目嗎?</string>
|
||||
<string name="num_items_deleted">已刪除 %1$d 項</string>
|
||||
<string name="delete_theme">刪除主題</string>
|
||||
<string name="delete_theme_msg">確定要刪除主題 “%1$s” 嗎?</string>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="app_name_release">Fcitx5</string>
|
||||
<string name="app_name_debug">Fcitx5 (Debug)</string>
|
||||
<string name="save">Save</string>
|
||||
@ -59,6 +59,9 @@
|
||||
<string name="clear_clb_db">Clear clipboard database</string>
|
||||
<string name="pin">Pin</string>
|
||||
<string name="unpin">Unpin</string>
|
||||
<string name="favorite" tools:ignore="MissingTranslation">Add to favorites</string>
|
||||
<string name="unfavorite" tools:ignore="MissingTranslation">Remove from favorites</string>
|
||||
<string name="favorites" tools:ignore="MissingTranslation">Favorites</string>
|
||||
<string name="delete">Delete</string>
|
||||
<string name="clipboard">Clipboard</string>
|
||||
<string name="keyboard_height">Keyboard height</string>
|
||||
@ -80,6 +83,7 @@
|
||||
<string name="text_editing">Text Editing</string>
|
||||
<string name="instruction_enable_clipboard_listening">To use clipboard management, please enable clipboard listening in settings</string>
|
||||
<string name="instruction_copy">Try copying something</string>
|
||||
<string name="instruction_favorite" tools:ignore="MissingTranslation">Long press a clipboard item to add it to favorites</string>
|
||||
<string name="clear">Clear</string>
|
||||
<string name="export">Export</string>
|
||||
<string name="app_crash">Application crashed</string>
|
||||
@ -209,6 +213,7 @@
|
||||
<string name="horizontal_candidate_always_fill">Always fill width</string>
|
||||
<string name="inline_suggestions">Show inline autofill suggestions</string>
|
||||
<string name="delete_all_except_pinned">Delete all items except pinned ones?</string>
|
||||
<string name="delete_all_except_pinned_and_favorites" tools:ignore="MissingTranslation">Delete all items except pinned and favorite ones?</string>
|
||||
<string name="num_items_deleted">%1$d item(s) deleted</string>
|
||||
<string name="delete_theme">Delete Theme</string>
|
||||
<string name="delete_theme_msg">Do you really want to delete theme \"%1$s\"?</string>
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
|
||||
*/
|
||||
package org.fcitx.fcitx5.android
|
||||
|
||||
import org.fcitx.fcitx5.android.input.clipboard.ClipboardPanelSection
|
||||
import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ClipboardStateMachineTest {
|
||||
|
||||
@Test
|
||||
fun listeningDisabledAlwaysShowsEnableInstruction() {
|
||||
ClipboardPanelSection.entries.forEach { section ->
|
||||
listOf(true, false).forEach { empty ->
|
||||
assertEquals(
|
||||
ClipboardStateMachine.State.EnableListening,
|
||||
ClipboardStateMachine.resolve(false, section, empty)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonEmptySectionShowsEntries() {
|
||||
ClipboardPanelSection.entries.forEach { section ->
|
||||
assertEquals(
|
||||
ClipboardStateMachine.State.Normal,
|
||||
ClipboardStateMachine.resolve(true, section, false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptySectionShowsSectionSpecificInstruction() {
|
||||
assertEquals(
|
||||
ClipboardStateMachine.State.AddMore,
|
||||
ClipboardStateMachine.resolve(true, ClipboardPanelSection.Clipboard, true)
|
||||
)
|
||||
assertEquals(
|
||||
ClipboardStateMachine.State.NoFavorites,
|
||||
ClipboardStateMachine.resolve(true, ClipboardPanelSection.Favorites, true)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -66,7 +66,7 @@ class ThemeSerializationTest {
|
||||
|
||||
@Test
|
||||
fun version2() {
|
||||
// Version 2.0
|
||||
// Version 2.0, outdated
|
||||
val raw = """
|
||||
{
|
||||
"name":"",
|
||||
@ -98,7 +98,7 @@ class ThemeSerializationTest {
|
||||
}
|
||||
""".trimIndent()
|
||||
val (decoded, migrated) = raw.toCustomTheme()
|
||||
Assert.assertEquals("Migration shouldn't happen", false, migrated)
|
||||
Assert.assertEquals("Migration should happen", true, migrated)
|
||||
Assert.assertEquals("Round trip", decoded, decoded.toJson().toCustomTheme().first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
78
docs/clipboard-enhancement-plan.md
Normal file
78
docs/clipboard-enhancement-plan.md
Normal file
@ -0,0 +1,78 @@
|
||||
# F5Clipboard 剪贴板增强计划
|
||||
|
||||
## 总体方案与阶段
|
||||
|
||||
基于当前 fork 的主 `app` 模块开发,保持上游兼容,不拆成独立插件或增强版应用。
|
||||
|
||||
- **阶段 1:顶栏与收藏**(功能完成;全仓既有门禁问题待处理)
|
||||
- **阶段 2:智能管理**(待办:分类筛选、验证码和追踪口令清理)
|
||||
- **阶段 3:常用词**(待办:自定义短语、三字符匹配与一键补全)
|
||||
|
||||
上游相关需求:[fcitx5-android/fcitx5-android#456](https://github.com/fcitx5-android/fcitx5-android/issues/456)。
|
||||
|
||||
## 阶段 1:顶栏与收藏
|
||||
|
||||
### 数据与接口
|
||||
|
||||
- Room 数据库由 v4 升到 v5,为剪贴板条目增加 `favorite` 状态,并通过自动迁移保留旧数据。
|
||||
- 使用 `ClipboardEntryFilter` 统一“全部”和“收藏”查询入口,为后续分类过滤保留扩展点。
|
||||
- 使用独立的 `ClipboardPanelSection` 表示键盘面板顶栏页面;阶段 3 再扩展“常用词”。
|
||||
- 阶段 1 不增加 `category`、`expiresAt` 等尚未使用的字段,也不修改插件 AIDL。
|
||||
|
||||
### 收藏与删除语义
|
||||
|
||||
- 收藏与置顶相互独立,同一条目可以同时置顶和收藏。
|
||||
- 剪贴板页显示全部未删除条目;收藏页只显示收藏条目,均按置顶优先、时间倒序排列。
|
||||
- 历史上限裁剪和一键清空只处理既未置顶、也未收藏的条目。
|
||||
- 收藏属于软保护:用户仍可通过菜单或滑动手动删除,并可撤销。
|
||||
- 重复复制已有文本时只更新时间,保留置顶和收藏状态。
|
||||
|
||||
### 顶栏与交互
|
||||
|
||||
- 顶栏布局:返回按钮 + “剪贴板 / 收藏”标签 + 清空按钮。
|
||||
- 默认进入剪贴板页,不持久化上次标签。
|
||||
- 收藏页隐藏清空按钮;没有收藏时显示独立空状态。
|
||||
- 长按菜单提供“收藏/取消收藏”;条目使用星标展示状态。
|
||||
- 新增英文、简体中文和繁体中文资源,其他语言回退到英文。
|
||||
|
||||
## 后续阶段预留
|
||||
|
||||
### 阶段 2:智能管理
|
||||
|
||||
- 扩展剪贴板过滤接口,支持电话、邮箱、网址、验证码、追踪口令和其他类型。
|
||||
- 在写入链路引入独立内容分析器,生成分类和可选过期时间;规则按文本判断,不依赖来源 App。
|
||||
- 收藏和置顶覆盖自动过期规则。
|
||||
- 开始阶段 2 前确认分类优先级、有效期、识别规则和误删恢复策略。
|
||||
|
||||
### 阶段 3:常用词
|
||||
|
||||
- 顶栏扩展为“剪贴板 / 收藏 / 常用词”。
|
||||
- 复用现有 `QuickPhraseManager`、`QuickPhraseEntry` 和快速输入文件格式。
|
||||
- 提供键盘内新增、编辑、删除和直接上屏。
|
||||
- 三字符预测接入现有 preedit/candidate 广播链路;开始阶段 3 前确认匹配和候选优先级。
|
||||
|
||||
## 阶段 1 验证清单
|
||||
|
||||
- [x] v4 数据库无损迁移到 v5,旧条目默认未收藏。
|
||||
- [x] 收藏切换、全部查询、收藏查询和排序正确。
|
||||
- [x] 历史裁剪与一键清空跳过置顶及收藏。
|
||||
- [x] 手动删除收藏条目及撤销正常。
|
||||
- [x] 重复复制不会清除收藏状态。
|
||||
- [x] 顶栏切换和各类空状态正确。
|
||||
- [ ] 全仓门禁完全通过(`testDebugUnitTest`、`assembleDebug` 已通过;`lintDebug` 被 269 个既有错误阻塞)。
|
||||
- [x] API 35 模拟器上的阶段 1 自动化与手工交互验证通过。
|
||||
|
||||
### 2026-07-30 验收记录
|
||||
|
||||
- `testDebugUnitTest`、`assembleDebug`、`assembleDebugAndroidTest` 均通过。
|
||||
- API 35 上通过 AndroidJUnitRunner 直接执行阶段 1 的 6 个仪器测试;Gradle `connectedDebugAndroidTest` 的 UTP 启动被本机离线依赖缓存阻塞。
|
||||
- 手工验证了收藏/取消收藏、填充星标、收藏筛选、独立空状态、批量清空保护,以及重新进入时默认回到剪贴板页。
|
||||
- `lintDebug` 完成扫描,本次改动没有命中 lint 报告;任务仍因仓库原有的 269 个错误失败,首个错误位于未改动的 `fragment_setup.xml`。
|
||||
- 仓库原有 `FcitxTest` 在 API 35 上进入首项测试后超过 90 秒没有完成;阶段 1 的独立仪器测试不受影响。
|
||||
|
||||
## 环境约定
|
||||
|
||||
- 使用 Android Platform 36、Build Tools 36.1.0、NDK 28.0.13004108、CMake 3.31.6。
|
||||
- Windows 原生构建使用 MSYS2 UCRT64 的 ECM/gettext。
|
||||
- 本地优先使用 Android Studio 自带 JBR 21;CI 继续验证 JDK 17。
|
||||
- Debug 包沿用 `.debug` 后缀,与正式版共存。
|
||||
@ -36,6 +36,7 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref =
|
||||
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
||||
androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "room" }
|
||||
androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
|
||||
androidx-startup = { module = "androidx.startup:startup-runtime", version = "1.2.0" }
|
||||
androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version = "1.1.0" }
|
||||
material = { module = "com.google.android.material:material", version = "1.14.0" }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user