Skip to content

Commit 0310d1d

Browse files
committed
自动提交
1 parent fa808f7 commit 0310d1d

File tree

9 files changed

+435
-20
lines changed

9 files changed

+435
-20
lines changed

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

Lines changed: 146 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -519,9 +519,10 @@ class BitwardenRepository(private val context: Context) {
519519
* 执行完整同步
520520
*
521521
* 同步流程:
522-
* 1. 从服务器拉取最新数据
523-
* 2. 上传本地创建的条目到服务器
524-
* 3. 上传本地修改的条目到服务器
522+
* 1. 先处理本地待删除操作(delete)
523+
* 2. 上传本地创建的条目到服务器(create)
524+
* 3. 上传本地修改的条目到服务器(update)
525+
* 4. 从服务器拉取最新数据(pull)
525526
*/
526527
suspend fun sync(vaultId: Long): SyncResult = withContext(Dispatchers.IO) {
527528
syncMutex.withLock {
@@ -547,21 +548,24 @@ class BitwardenRepository(private val context: Context) {
547548
}
548549
}
549550

550-
// 1. 先上传本地创建的条目到服务器
551+
// 1. 先处理本地待删除操作(delete)
552+
val processedDeleteCount = syncService.processPendingOperations(vault, accessToken, symmetricKey)
553+
554+
// 2. 再上传本地创建的条目到服务器(create)
551555
val uploadResult = syncService.uploadLocalEntries(vault, accessToken, symmetricKey)
552556
val uploadedCount = when (uploadResult) {
553557
is takagi.ru.monica.bitwarden.service.UploadResult.Success -> uploadResult.uploaded
554558
else -> 0
555559
}
556560

557-
// 2. 再上传本地已修改的条目到服务器
561+
// 3. 上传本地已修改的条目到服务器(update)
558562
val modifiedUploadResult = syncService.uploadModifiedEntries(vault, accessToken, symmetricKey)
559563
val modifiedUploadedCount = when (modifiedUploadResult) {
560564
is takagi.ru.monica.bitwarden.service.UploadResult.Success -> modifiedUploadResult.uploaded
561565
else -> 0
562566
}
563567

564-
// 3. 执行同步(从服务器拉取
568+
// 4. 执行同步(pull
565569
val result = syncService.fullSync(vault, accessToken, symmetricKey)
566570

567571
// 更新最后同步时间
@@ -570,7 +574,7 @@ class BitwardenRepository(private val context: Context) {
570574
when (result) {
571575
is ServiceSyncResult.Success -> {
572576
SyncResult.Success(
573-
syncedCount = result.ciphersAdded + result.ciphersUpdated + uploadedCount + modifiedUploadedCount,
577+
syncedCount = result.ciphersAdded + result.ciphersUpdated + uploadedCount + modifiedUploadedCount + processedDeleteCount,
574578
conflictCount = result.conflictsDetected
575579
)
576580
}
@@ -777,6 +781,103 @@ class BitwardenRepository(private val context: Context) {
777781
}
778782
}
779783

784+
suspend fun queueCipherDelete(
785+
vaultId: Long,
786+
cipherId: String,
787+
entryId: Long? = null,
788+
itemType: String = BitwardenPendingOperation.ITEM_TYPE_PASSWORD
789+
): Result<Unit> = withContext(Dispatchers.IO) {
790+
try {
791+
val vault = vaultDao.getVaultById(vaultId)
792+
?: return@withContext Result.failure(IllegalStateException("Vault 不存在"))
793+
794+
// 已有待删除操作时直接复用,保证幂等。
795+
val existingDelete = pendingOpDao.findActiveDeleteByCipher(vaultId, cipherId)
796+
if (existingDelete != null) {
797+
return@withContext Result.success(Unit)
798+
}
799+
800+
pendingOpDao.cancelActiveRestoreByCipher(vaultId, cipherId)
801+
802+
entryId?.let { id ->
803+
pendingOpDao.deletePendingForEntryAndType(id, itemType)
804+
}
805+
806+
pendingOpDao.insert(
807+
BitwardenPendingOperation(
808+
vaultId = vault.id,
809+
entryId = entryId,
810+
bitwardenCipherId = cipherId,
811+
itemType = itemType,
812+
operationType = BitwardenPendingOperation.OP_DELETE,
813+
targetType = BitwardenPendingOperation.TARGET_CIPHER,
814+
payloadJson = "{}",
815+
status = BitwardenPendingOperation.STATUS_PENDING
816+
)
817+
)
818+
819+
Result.success(Unit)
820+
} catch (e: Exception) {
821+
Log.e(TAG, "加入 Bitwarden 删除队列失败", e)
822+
Result.failure(e)
823+
}
824+
}
825+
826+
suspend fun queueCipherRestore(
827+
vaultId: Long,
828+
cipherId: String,
829+
entryId: Long? = null,
830+
itemType: String = BitwardenPendingOperation.ITEM_TYPE_PASSWORD
831+
): Result<Unit> = withContext(Dispatchers.IO) {
832+
try {
833+
val vault = vaultDao.getVaultById(vaultId)
834+
?: return@withContext Result.failure(IllegalStateException("Vault 不存在"))
835+
836+
val existingRestore = pendingOpDao.findActiveRestoreByCipher(vaultId, cipherId)
837+
if (existingRestore != null) {
838+
return@withContext Result.success(Unit)
839+
}
840+
841+
val existingDelete = pendingOpDao.findActiveDeleteByCipher(vaultId, cipherId)
842+
if (existingDelete != null) {
843+
pendingOpDao.cancelActiveDeleteByCipher(vaultId, cipherId)
844+
return@withContext Result.success(Unit)
845+
}
846+
847+
entryId?.let { id ->
848+
pendingOpDao.deletePendingForEntryAndType(id, itemType)
849+
}
850+
851+
pendingOpDao.insert(
852+
BitwardenPendingOperation(
853+
vaultId = vault.id,
854+
entryId = entryId,
855+
bitwardenCipherId = cipherId,
856+
itemType = itemType,
857+
operationType = BitwardenPendingOperation.OP_RESTORE,
858+
targetType = BitwardenPendingOperation.TARGET_CIPHER,
859+
payloadJson = "{}",
860+
status = BitwardenPendingOperation.STATUS_PENDING
861+
)
862+
)
863+
864+
Result.success(Unit)
865+
} catch (e: Exception) {
866+
Log.e(TAG, "加入 Bitwarden 恢复队列失败", e)
867+
Result.failure(e)
868+
}
869+
}
870+
871+
suspend fun cancelPendingCipherDelete(vaultId: Long, cipherId: String): Result<Unit> = withContext(Dispatchers.IO) {
872+
try {
873+
pendingOpDao.cancelActiveDeleteByCipher(vaultId, cipherId)
874+
Result.success(Unit)
875+
} catch (e: Exception) {
876+
Log.e(TAG, "取消 Bitwarden 删除队列失败", e)
877+
Result.failure(e)
878+
}
879+
}
880+
780881
suspend fun deleteCipher(vaultId: Long, cipherId: String): Result<Unit> = withContext(Dispatchers.IO) {
781882
try {
782883
val vault = vaultDao.getVaultById(vaultId)
@@ -815,6 +916,44 @@ class BitwardenRepository(private val context: Context) {
815916
}
816917
}
817918

919+
suspend fun permanentDeleteCipher(vaultId: Long, cipherId: String): Result<Unit> = withContext(Dispatchers.IO) {
920+
try {
921+
val vault = vaultDao.getVaultById(vaultId)
922+
?: return@withContext Result.failure(IllegalStateException("Vault 不存在"))
923+
924+
if (!isVaultUnlocked(vaultId)) {
925+
return@withContext Result.failure(IllegalStateException("Vault 未解锁"))
926+
}
927+
928+
var accessToken = accessTokenCache[vaultId]
929+
?: return@withContext Result.failure(IllegalStateException("令牌不可用"))
930+
931+
val expiresAt = vault.accessTokenExpiresAt ?: 0
932+
if (expiresAt <= System.currentTimeMillis() + 60000) {
933+
val refreshed = refreshToken(vault)
934+
if (refreshed != null) {
935+
accessToken = refreshed
936+
accessTokenCache[vaultId] = refreshed
937+
} else {
938+
return@withContext Result.failure(IllegalStateException("Token 刷新失败,请重新登录"))
939+
}
940+
}
941+
942+
val vaultApi = apiManager.getVaultApi(vault.apiUrl)
943+
val response = vaultApi.permanentDeleteCipher("Bearer $accessToken", cipherId)
944+
if (!response.isSuccessful && response.code() != 404) {
945+
return@withContext Result.failure(
946+
IllegalStateException("永久删除失败: ${response.code()} ${response.message()}")
947+
)
948+
}
949+
950+
Result.success(Unit)
951+
} catch (e: Exception) {
952+
Log.e(TAG, "永久删除 Bitwarden Cipher 失败", e)
953+
Result.failure(e)
954+
}
955+
}
956+
818957
suspend fun getSends(vaultId: Long): List<BitwardenSend> = withContext(Dispatchers.IO) {
819958
sendDao.getSendsByVault(vaultId)
820959
}

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

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class BitwardenSyncService(
4545
private val conflictDao = database.bitwardenConflictBackupDao()
4646
private val pendingOpDao = database.bitwardenPendingOperationDao()
4747
private val passwordEntryDao = database.passwordEntryDao()
48+
private val secureItemDao = database.secureItemDao()
4849
private val securityManager = SecurityManager(context)
4950

5051
// 多类型 Cipher 同步处理器
@@ -161,6 +162,11 @@ class BitwardenSyncService(
161162
var ciphersUpdated = 0
162163
var conflictsDetected = 0
163164
var sendsSynced = 0
165+
val activeServerCipherIds = response.ciphers
166+
.asSequence()
167+
.filter { it.deletedDate == null }
168+
.map { it.id }
169+
.toList()
164170

165171
// 1. 同步文件夹
166172
response.folders.forEach { folderApi ->
@@ -214,7 +220,16 @@ class BitwardenSyncService(
214220
android.util.Log.w(TAG, "Failed to sync cipher ${cipherApi.id}: ${e.message}")
215221
}
216222
}
217-
223+
224+
// 2.1 清理服务器已不存在的本地 Cipher(delete-wins)
225+
if (activeServerCipherIds.isEmpty()) {
226+
passwordEntryDao.deleteAllSyncedBitwardenEntries(vault.id)
227+
secureItemDao.deleteAllSyncedBitwardenEntries(vault.id)
228+
} else {
229+
passwordEntryDao.deleteBitwardenEntriesNotIn(vault.id, activeServerCipherIds)
230+
secureItemDao.deleteBitwardenEntriesNotIn(vault.id, activeServerCipherIds)
231+
}
232+
218233
// 3. 清理已删除的文件夹 (服务器上不存在的)
219234
val serverFolderIds = response.folders.map { it.id }
220235
folderDao.deleteNotIn(vault.id, serverFolderIds)
@@ -516,7 +531,7 @@ class BitwardenSyncService(
516531
accessToken: String,
517532
symmetricKey: SymmetricCryptoKey
518533
): Int = withContext(Dispatchers.IO) {
519-
val pendingOps = pendingOpDao.getPendingOperationsByVault(vault.id)
534+
val pendingOps = pendingOpDao.getRunnableOperationsByVault(vault.id)
520535
var processed = 0
521536

522537
for (op in pendingOps) {
@@ -525,6 +540,7 @@ class BitwardenSyncService(
525540
BitwardenPendingOperation.OP_CREATE -> processCreateOperation(vault, op, accessToken, symmetricKey)
526541
BitwardenPendingOperation.OP_UPDATE -> processUpdateOperation(vault, op, accessToken, symmetricKey)
527542
BitwardenPendingOperation.OP_DELETE -> processDeleteOperation(vault, op, accessToken)
543+
BitwardenPendingOperation.OP_RESTORE -> processRestoreOperation(vault, op, accessToken)
528544
else -> false
529545
}
530546

@@ -578,6 +594,15 @@ class BitwardenSyncService(
578594
val cipherId = op.bitwardenCipherId ?: return false
579595
return deleteRemoteCipher(vault, cipherId, accessToken)
580596
}
597+
598+
private suspend fun processRestoreOperation(
599+
vault: BitwardenVault,
600+
op: BitwardenPendingOperation,
601+
accessToken: String
602+
): Boolean {
603+
val cipherId = op.bitwardenCipherId ?: return false
604+
return restoreRemoteCipher(vault, cipherId, accessToken)
605+
}
581606

582607
// ========== 上传本地条目到 Bitwarden ==========
583608

@@ -782,13 +807,32 @@ class BitwardenSyncService(
782807
authorization = "Bearer $accessToken",
783808
cipherId = cipherId
784809
)
785-
786-
return response.isSuccessful
810+
811+
return response.isSuccessful || response.code() == 404
787812
} catch (e: Exception) {
788813
android.util.Log.e(TAG, "Delete remote cipher failed: ${e.message}", e)
789814
return false
790815
}
791816
}
817+
818+
private suspend fun restoreRemoteCipher(
819+
vault: BitwardenVault,
820+
cipherId: String,
821+
accessToken: String
822+
): Boolean {
823+
try {
824+
val vaultApi = apiManager.getVaultApi(vault.apiUrl)
825+
val response = vaultApi.restoreCipher(
826+
authorization = "Bearer $accessToken",
827+
cipherId = cipherId
828+
)
829+
830+
return response.isSuccessful || response.code() == 400 || response.code() == 404
831+
} catch (e: Exception) {
832+
android.util.Log.e(TAG, "Restore remote cipher failed: ${e.message}", e)
833+
return false
834+
}
835+
}
792836

793837
/**
794838
* 将 PasswordEntry 转换为加密的 CipherCreateRequest

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class CipherSyncProcessor(
3939
private val passwordEntryDao = database.passwordEntryDao()
4040
private val secureItemDao = database.secureItemDao()
4141
private val passkeyDao = database.passkeyDao()
42+
private val pendingOpDao = database.bitwardenPendingOperationDao()
4243
private val securityManager = SecurityManager(context)
4344
private val json = Json {
4445
ignoreUnknownKeys = true
@@ -103,6 +104,7 @@ class CipherSyncProcessor(
103104

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

107109
// 解密字段
108110
val name = decryptString(cipher.name, symmetricKey) ?: "Untitled"
@@ -122,6 +124,9 @@ class CipherSyncProcessor(
122124
val encryptedPassword = securityManager.encryptData(password)
123125

124126
if (existing == null) {
127+
if (hasPendingDelete) {
128+
return CipherSyncResult.Skipped("Pending local delete")
129+
}
125130
// 创建新条目(不吞并本地同名条目,保持数据源独立)
126131
val newEntry = PasswordEntry(
127132
title = name,
@@ -143,6 +148,9 @@ class CipherSyncProcessor(
143148
passwordEntryDao.insert(newEntry)
144149
return CipherSyncResult.Added
145150
} else {
151+
if (existing.isDeleted || hasPendingDelete) {
152+
return CipherSyncResult.Skipped("Local delete wins")
153+
}
146154
// 更新现有条目
147155
if (existing.bitwardenLocalModified) {
148156
if (existing.bitwardenVaultId != vault.id || existing.bitwardenFolderId != cipher.folderId) {
@@ -184,7 +192,7 @@ class CipherSyncProcessor(
184192
if (allEntries.isEmpty()) return null
185193

186194
val canonical = allEntries.maxWithOrNull(
187-
compareBy<PasswordEntry> { if (it.bitwardenLocalModified) 1 else 0 }
195+
compareBy<PasswordEntry> { if (it.isDeleted) 2 else if (it.bitwardenLocalModified) 1 else 0 }
188196
.thenBy { if (hasLikelyNonBlankPassword(it)) 1 else 0 }
189197
.thenBy { it.updatedAt.time }
190198
.thenBy { it.id }

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

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,9 +346,41 @@ interface PasswordEntryDao {
346346
/**
347347
* 根据 Bitwarden Cipher ID 获取所有条目(用于清理历史重复数据)
348348
*/
349-
@Query("SELECT * FROM password_entries WHERE bitwarden_cipher_id = :cipherId AND isDeleted = 0")
349+
@Query("SELECT * FROM password_entries WHERE bitwarden_cipher_id = :cipherId")
350350
suspend fun getAllByBitwardenCipherId(cipherId: String): List<PasswordEntry>
351351

352+
/**
353+
* 删除指定 Vault 下所有已同步的 Bitwarden 密码条目。
354+
* 仅删除未进入回收站的条目,避免覆盖本地删除墓碑。
355+
*/
356+
@Query("""
357+
DELETE FROM password_entries
358+
WHERE bitwarden_vault_id = :vaultId
359+
AND bitwarden_cipher_id IS NOT NULL
360+
AND isDeleted = 0
361+
""")
362+
suspend fun deleteAllSyncedBitwardenEntries(vaultId: Long)
363+
364+
/**
365+
* 清理服务器不存在的 Bitwarden 密码条目(delete-wins)。
366+
*/
367+
@Query("""
368+
DELETE FROM password_entries
369+
WHERE bitwarden_vault_id = :vaultId
370+
AND bitwarden_cipher_id IS NOT NULL
371+
AND isDeleted = 0
372+
AND bitwarden_cipher_id NOT IN (:keepIds)
373+
AND NOT EXISTS (
374+
SELECT 1
375+
FROM bitwarden_pending_operations op
376+
WHERE op.vault_id = :vaultId
377+
AND op.bitwarden_cipher_id = password_entries.bitwarden_cipher_id
378+
AND op.operation_type = 'RESTORE'
379+
AND op.status IN ('PENDING', 'IN_PROGRESS', 'FAILED')
380+
)
381+
""")
382+
suspend fun deleteBitwardenEntriesNotIn(vaultId: Long, keepIds: List<String>)
383+
352384
/**
353385
* 查找本地重复条目(仅本地库)
354386
* 用于 Bitwarden 同步时合并本地条目,避免重复

0 commit comments

Comments
 (0)