Skip to content

Commit 6fbce53

Browse files
committed
save changes
1 parent 7cdb3eb commit 6fbce53

26 files changed

+267
-110
lines changed

Monica for Android/app/src/main/java/takagi/ru/monica/bitwarden/repository/BitwardenRepository.kt

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package takagi.ru.monica.bitwarden.repository
33
import android.content.Context
44
import android.util.Base64
55
import android.util.Log
6+
import androidx.room.withTransaction
67
import androidx.security.crypto.EncryptedSharedPreferences
78
import androidx.security.crypto.MasterKey
89
import kotlinx.coroutines.Dispatchers
@@ -130,6 +131,8 @@ class BitwardenRepository(private val context: Context) {
130131
private val conflictDao = database.bitwardenConflictBackupDao()
131132
private val pendingOpDao = database.bitwardenPendingOperationDao()
132133
private val passwordEntryDao = database.passwordEntryDao()
134+
private val secureItemDao = database.secureItemDao()
135+
private val passkeyDao = database.passkeyDao()
133136
private val categoryDao = database.categoryDao()
134137

135138
// Services
@@ -164,6 +167,8 @@ class BitwardenRepository(private val context: Context) {
164167
suspend fun getAllVaults(): List<BitwardenVault> = withContext(Dispatchers.IO) {
165168
vaultDao.getAllVaults()
166169
}
170+
171+
fun getAllVaultsFlow(): Flow<List<BitwardenVault>> = vaultDao.getAllVaultsFlow()
167172

168173
/**
169174
* 获取活跃的 Vault
@@ -517,19 +522,24 @@ class BitwardenRepository(private val context: Context) {
517522
// 清除缓存
518523
symmetricKeyCache.remove(vaultId)
519524
accessTokenCache.remove(vaultId)
520-
521-
// 删除该 Vault 的所有密码条目
522-
passwordEntryDao.deleteAllByBitwardenVaultId(vaultId)
523-
524-
// 删除文件夹
525-
folderDao.deleteByVault(vaultId)
526525

527-
// 删除 Send 缓存
528-
sendDao.deleteByVault(vaultId)
529-
530-
// 删除 Vault
531-
vaultDao.deleteById(vaultId)
532-
526+
database.withTransaction {
527+
// 先清理所有引用 vault_id 的表,避免外键约束导致登出失败。
528+
pendingOpDao.deleteByVault(vaultId)
529+
conflictDao.deleteByVault(vaultId)
530+
sendDao.deleteByVault(vaultId)
531+
folderDao.deleteByVault(vaultId)
532+
533+
// 清理本地业务数据
534+
passwordEntryDao.deleteAllByBitwardenVaultId(vaultId)
535+
secureItemDao.deleteAllByBitwardenVaultId(vaultId)
536+
passkeyDao.deleteAllByBitwardenVaultId(vaultId)
537+
categoryDao.unlinkByVaultId(vaultId)
538+
539+
// 最后删除 Vault 本体
540+
vaultDao.deleteById(vaultId)
541+
}
542+
533543
// 重置活跃 Vault
534544
if (securePrefs.getLong(KEY_ACTIVE_VAULT_ID, -1) == vaultId) {
535545
securePrefs.edit().remove(KEY_ACTIVE_VAULT_ID).apply()
@@ -1189,7 +1199,7 @@ class BitwardenRepository(private val context: Context) {
11891199

11901200
// 获取对应的密码条目
11911201
val cipherId = conflict.bitwardenCipherId ?: return@withContext false
1192-
val entry = passwordEntryDao.getByBitwardenCipherId(cipherId)
1202+
val entry = passwordEntryDao.getByBitwardenCipherIdInVault(conflict.vaultId, cipherId)
11931203

11941204
if (entry != null) {
11951205
// 使用备份的服务器数据更新本地条目

Monica for Android/app/src/main/java/takagi/ru/monica/bitwarden/service/BitwardenSyncService.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ class BitwardenSyncService(
325325
}
326326

327327
// 查找本地是否存在此 Cipher
328-
val existingEntry = passwordEntryDao.getByBitwardenCipherId(cipherApi.id)
328+
val existingEntry = passwordEntryDao.getByBitwardenCipherIdInVault(vault.id, cipherApi.id)
329329

330330
if (existingEntry == null) {
331331
// 新建条目

Monica for Android/app/src/main/java/takagi/ru/monica/bitwarden/service/CipherSyncProcessor.kt

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ class CipherSyncProcessor(
109109
val login = cipher.login ?: return CipherSyncResult.Skipped("No login data")
110110

111111
// 先按 cipherId 收敛历史重复副本,避免“同一条目异常膨胀”。
112-
val existing = resolveCanonicalPasswordEntry(cipher.id)
112+
val existing = resolveCanonicalPasswordEntry(vault.id, cipher.id)
113113
val hasPendingDelete = pendingOpDao.hasActiveDeleteByCipher(vault.id, cipher.id)
114114

115115
// 解密字段
@@ -235,8 +235,8 @@ class CipherSyncProcessor(
235235
}
236236
}
237237

238-
private suspend fun resolveCanonicalPasswordEntry(cipherId: String): PasswordEntry? {
239-
val allEntries = passwordEntryDao.getAllByBitwardenCipherId(cipherId)
238+
private suspend fun resolveCanonicalPasswordEntry(vaultId: Long, cipherId: String): PasswordEntry? {
239+
val allEntries = passwordEntryDao.getAllByBitwardenCipherIdInVault(vaultId, cipherId)
240240
if (allEntries.isEmpty()) return null
241241

242242
val canonical = allEntries.maxWithOrNull(
@@ -290,7 +290,7 @@ class CipherSyncProcessor(
290290
val account = decryptString(login.username, symmetricKey) ?: ""
291291

292292
// 查找本地是否存在
293-
val existing = secureItemDao.getByBitwardenCipherId(cipher.id)
293+
val existing = secureItemDao.getByBitwardenCipherIdInVault(vault.id, cipher.id)
294294

295295
// 构建 TOTP 数据
296296
val totpData = TotpData(
@@ -367,7 +367,7 @@ class CipherSyncProcessor(
367367
val name = decryptString(cipher.name, symmetricKey) ?: "Note"
368368
val notes = decryptString(cipher.notes, symmetricKey) ?: ""
369369

370-
val existing = secureItemDao.getByBitwardenCipherId(cipher.id)
370+
val existing = secureItemDao.getByBitwardenCipherIdInVault(vault.id, cipher.id)
371371

372372
// 构建笔记数据
373373
val noteData = NoteData(content = notes)
@@ -445,7 +445,7 @@ class CipherSyncProcessor(
445445
val cvv = decryptString(card.code, symmetricKey) ?: ""
446446
val brand = decryptString(card.brand, symmetricKey) ?: ""
447447

448-
val existing = secureItemDao.getByBitwardenCipherId(cipher.id)
448+
val existing = secureItemDao.getByBitwardenCipherIdInVault(vault.id, cipher.id)
449449

450450
// 构建银行卡数据(使用 CardMapper.kt 中的 CardItemData 结构)
451451
val cardData = BankCardData(
@@ -544,7 +544,7 @@ class CipherSyncProcessor(
544544
}
545545
}
546546

547-
val existing = secureItemDao.getByBitwardenCipherId(cipher.id)
547+
val existing = secureItemDao.getByBitwardenCipherIdInVault(vault.id, cipher.id)
548548

549549
// 构建证件数据(使用 IdentityMapper.kt 中的 DocumentItemData 结构)
550550
val docData = DocumentData(
@@ -642,7 +642,7 @@ class CipherSyncProcessor(
642642
if (decodedCredentials.isEmpty()) {
643643
// 兼容历史 Monica marker-only passkey:至少落一个引用记录
644644
val referenceId = buildReferenceCredentialId(cipher.id, 0)
645-
val existing = passkeyDao.getByBitwardenCipherId(cipher.id)
645+
val existing = passkeyDao.getByBitwardenCipherIdInVault(vault.id, cipher.id)
646646
?: passkeyDao.getPasskeyById(referenceId)
647647

648648
val rpId = fallbackRpId
@@ -774,7 +774,7 @@ class CipherSyncProcessor(
774774
}
775775
}
776776

777-
val staleEntries = passkeyDao.getAllByBitwardenCipherId(cipher.id)
777+
val staleEntries = passkeyDao.getAllByBitwardenCipherIdInVault(vault.id, cipher.id)
778778
.filterNot { keepCredentialIds.contains(it.credentialId) }
779779
staleEntries.forEach { passkeyDao.delete(it) }
780780

Monica for Android/app/src/main/java/takagi/ru/monica/bitwarden/ui/BitwardenSettingsScreen.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package takagi.ru.monica.bitwarden.ui
22

3+
import android.widget.Toast
34
import androidx.compose.animation.AnimatedVisibility
45
import androidx.compose.animation.core.animateFloatAsState
56
import androidx.compose.foundation.clickable
@@ -11,6 +12,7 @@ import androidx.compose.material.icons.filled.*
1112
import androidx.compose.material.icons.outlined.*
1213
import androidx.compose.material3.*
1314
import androidx.compose.runtime.*
15+
import androidx.compose.ui.platform.LocalContext
1416
import androidx.compose.ui.Alignment
1517
import androidx.compose.ui.Modifier
1618
import androidx.compose.ui.draw.clip
@@ -53,6 +55,7 @@ fun BitwardenSettingsScreen(
5355
val isSyncOnWifiOnly by viewModel.isSyncOnWifiOnlyFlow.collectAsState()
5456
val pendingCount by viewModel.pendingSyncCount.collectAsState()
5557
val failedCount by viewModel.failedSyncCount.collectAsState()
58+
val context = LocalContext.current
5659

5760
// 对话框状态
5861
var showLogoutConfirmDialog by remember { mutableStateOf(false) }
@@ -69,6 +72,12 @@ fun BitwardenSettingsScreen(
6972
is BitwardenViewModel.BitwardenEvent.NavigateToLogin -> {
7073
onNavigateToLogin()
7174
}
75+
is BitwardenViewModel.BitwardenEvent.ShowSuccess -> {
76+
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
77+
}
78+
is BitwardenViewModel.BitwardenEvent.ShowError -> {
79+
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
80+
}
7281
else -> {}
7382
}
7483
}

Monica for Android/app/src/main/java/takagi/ru/monica/data/CategoryDao.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,10 @@ interface CategoryDao {
5757
*/
5858
@Query("UPDATE categories SET bitwarden_vault_id = NULL, bitwarden_folder_id = NULL, sync_item_types = NULL WHERE bitwarden_folder_id = :folderId")
5959
suspend fun unlinkByFolderId(folderId: String)
60-
60+
61+
@Query("UPDATE categories SET bitwarden_vault_id = NULL, bitwarden_folder_id = NULL, sync_item_types = NULL WHERE bitwarden_vault_id = :vaultId")
62+
suspend fun unlinkByVaultId(vaultId: Long)
63+
6164
/**
6265
* 更新同步类型
6366
*/

Monica for Android/app/src/main/java/takagi/ru/monica/data/PasskeyDao.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,11 +179,30 @@ interface PasskeyDao {
179179
@Query("SELECT * FROM passkeys WHERE bitwarden_cipher_id = :cipherId LIMIT 1")
180180
suspend fun getByBitwardenCipherId(cipherId: String): PasskeyEntry?
181181

182+
@Query(
183+
"""
184+
SELECT * FROM passkeys
185+
WHERE bitwarden_vault_id = :vaultId
186+
AND bitwarden_cipher_id = :cipherId
187+
LIMIT 1
188+
"""
189+
)
190+
suspend fun getByBitwardenCipherIdInVault(vaultId: Long, cipherId: String): PasskeyEntry?
191+
182192
/**
183193
* 根据 Bitwarden Cipher ID 获取所有 Passkey
184194
*/
185195
@Query("SELECT * FROM passkeys WHERE bitwarden_cipher_id = :cipherId")
186196
suspend fun getAllByBitwardenCipherId(cipherId: String): List<PasskeyEntry>
197+
198+
@Query(
199+
"""
200+
SELECT * FROM passkeys
201+
WHERE bitwarden_vault_id = :vaultId
202+
AND bitwarden_cipher_id = :cipherId
203+
"""
204+
)
205+
suspend fun getAllByBitwardenCipherIdInVault(vaultId: Long, cipherId: String): List<PasskeyEntry>
187206

188207
/**
189208
* 获取指定 Vault 的所有 Passkeys

Monica for Android/app/src/main/java/takagi/ru/monica/data/PasskeyEntry.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ import androidx.room.PrimaryKey
3636
tableName = "passkeys",
3737
indices = [
3838
Index(value = ["rp_id"], name = "index_passkeys_rp_id"),
39-
Index(value = ["user_name"], name = "index_passkeys_user_name")
39+
Index(value = ["user_name"], name = "index_passkeys_user_name"),
40+
Index(
41+
value = ["bitwarden_vault_id", "bitwarden_cipher_id"],
42+
name = "index_passkeys_bitwarden_vault_cipher"
43+
)
4044
]
4145
)
4246
data class PasskeyEntry(

Monica for Android/app/src/main/java/takagi/ru/monica/data/PasswordDatabase.kt

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import takagi.ru.monica.data.bitwarden.*
2727
BitwardenConflictBackup::class,
2828
BitwardenPendingOperation::class
2929
],
30-
version = 43,
30+
version = 44,
3131
exportSchema = false
3232
)
3333
@TypeConverters(Converters::class)
@@ -1103,6 +1103,64 @@ abstract class PasswordDatabase : RoomDatabase() {
11031103
}
11041104
}
11051105

1106+
/**
1107+
* Migration 43 -> 44:
1108+
* 1. 清理 Bitwarden 多库场景下遗留的重复映射数据(按 vault+cipher 收敛)。
1109+
* 2. 为 password_entries / secure_items 添加 vault+cipher 唯一索引,防止重复插入。
1110+
* 3. 为 passkeys 添加 vault+cipher 查询索引(非唯一,兼容一个 cipher 多凭据)。
1111+
*/
1112+
private val MIGRATION_43_44 = object : androidx.room.migration.Migration(43, 44) {
1113+
override fun migrate(database: androidx.sqlite.db.SupportSQLiteDatabase) {
1114+
try {
1115+
android.util.Log.i("PasswordDatabase", "Starting migration 43→44: bitwarden vault+cipher uniqueness hardening")
1116+
1117+
database.execSQL(
1118+
"""
1119+
DELETE FROM password_entries
1120+
WHERE bitwarden_vault_id IS NOT NULL
1121+
AND bitwarden_cipher_id IS NOT NULL
1122+
AND id NOT IN (
1123+
SELECT MAX(id)
1124+
FROM password_entries
1125+
WHERE bitwarden_vault_id IS NOT NULL
1126+
AND bitwarden_cipher_id IS NOT NULL
1127+
GROUP BY bitwarden_vault_id, bitwarden_cipher_id
1128+
)
1129+
""".trimIndent()
1130+
)
1131+
1132+
database.execSQL(
1133+
"""
1134+
DELETE FROM secure_items
1135+
WHERE bitwarden_vault_id IS NOT NULL
1136+
AND bitwarden_cipher_id IS NOT NULL
1137+
AND id NOT IN (
1138+
SELECT MAX(id)
1139+
FROM secure_items
1140+
WHERE bitwarden_vault_id IS NOT NULL
1141+
AND bitwarden_cipher_id IS NOT NULL
1142+
GROUP BY bitwarden_vault_id, bitwarden_cipher_id
1143+
)
1144+
""".trimIndent()
1145+
)
1146+
1147+
database.execSQL(
1148+
"CREATE UNIQUE INDEX IF NOT EXISTS index_password_entries_bitwarden_vault_cipher_unique ON password_entries(bitwarden_vault_id, bitwarden_cipher_id)"
1149+
)
1150+
database.execSQL(
1151+
"CREATE UNIQUE INDEX IF NOT EXISTS index_secure_items_bitwarden_vault_cipher_unique ON secure_items(bitwarden_vault_id, bitwarden_cipher_id)"
1152+
)
1153+
database.execSQL(
1154+
"CREATE INDEX IF NOT EXISTS index_passkeys_bitwarden_vault_cipher ON passkeys(bitwarden_vault_id, bitwarden_cipher_id)"
1155+
)
1156+
1157+
android.util.Log.i("PasswordDatabase", "Migration 43→44 completed successfully")
1158+
} catch (e: Exception) {
1159+
android.util.Log.e("PasswordDatabase", "Migration 43→44 failed: ${e.message}")
1160+
}
1161+
}
1162+
}
1163+
11061164
fun getDatabase(context: Context): PasswordDatabase {
11071165
return INSTANCE ?: synchronized(this) {
11081166
val instance = Room.databaseBuilder(
@@ -1152,7 +1210,8 @@ abstract class PasswordDatabase : RoomDatabase() {
11521210
MIGRATION_39_40, // Passkey 模式字段
11531211
MIGRATION_40_41, // 密码归档字段
11541212
MIGRATION_41_42, // Passkey 文件夹/分组字段
1155-
MIGRATION_42_43 // 清理 legacy KeePass WebDAV 残余
1213+
MIGRATION_42_43, // 清理 legacy KeePass WebDAV 残余
1214+
MIGRATION_43_44 // Bitwarden 多库去重与唯一约束
11561215
)
11571216
.build()
11581217
INSTANCE = instance

Monica for Android/app/src/main/java/takagi/ru/monica/data/PasswordEntry.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,15 @@ import java.util.Date
1414
@Parcelize
1515
@Entity(
1616
tableName = "password_entries",
17-
indices = [Index(value = ["isDeleted"]), Index(value = ["isArchived"])]
17+
indices = [
18+
Index(value = ["isDeleted"]),
19+
Index(value = ["isArchived"]),
20+
Index(
21+
value = ["bitwarden_vault_id", "bitwarden_cipher_id"],
22+
unique = true,
23+
name = "index_password_entries_bitwarden_vault_cipher_unique"
24+
)
25+
]
1826
)
1927
data class PasswordEntry(
2028
@PrimaryKey(autoGenerate = true)

Monica for Android/app/src/main/java/takagi/ru/monica/data/PasswordEntryDao.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,12 +377,31 @@ interface PasswordEntryDao {
377377
@Query("SELECT * FROM password_entries WHERE bitwarden_cipher_id = :cipherId LIMIT 1")
378378
suspend fun getByBitwardenCipherId(cipherId: String): PasswordEntry?
379379

380+
@Query(
381+
"""
382+
SELECT * FROM password_entries
383+
WHERE bitwarden_vault_id = :vaultId
384+
AND bitwarden_cipher_id = :cipherId
385+
LIMIT 1
386+
"""
387+
)
388+
suspend fun getByBitwardenCipherIdInVault(vaultId: Long, cipherId: String): PasswordEntry?
389+
380390
/**
381391
* 根据 Bitwarden Cipher ID 获取所有条目(用于清理历史重复数据)
382392
*/
383393
@Query("SELECT * FROM password_entries WHERE bitwarden_cipher_id = :cipherId")
384394
suspend fun getAllByBitwardenCipherId(cipherId: String): List<PasswordEntry>
385395

396+
@Query(
397+
"""
398+
SELECT * FROM password_entries
399+
WHERE bitwarden_vault_id = :vaultId
400+
AND bitwarden_cipher_id = :cipherId
401+
"""
402+
)
403+
suspend fun getAllByBitwardenCipherIdInVault(vaultId: Long, cipherId: String): List<PasswordEntry>
404+
386405
/**
387406
* 删除指定 Vault 下所有已同步的 Bitwarden 密码条目。
388407
* 仅删除未进入回收站的条目,避免覆盖本地删除墓碑。

0 commit comments

Comments
 (0)