Be more lenient with attachment restore conditions.

This commit is contained in:
Greyson Parrelli
2026-01-07 17:00:45 -05:00
committed by jeffrey-signal
parent 3e5af23f43
commit 7c11239875
4 changed files with 213 additions and 1 deletions

View File

@@ -2393,6 +2393,81 @@ class AttachmentTable(
.run()
}
/**
* For attachments that match the given media objects (by computing mediaId from plaintextHash + remoteKey),
* update their archive transfer state to FINISHED and set the archive CDN.
*
* This is used during reconciliation to fix attachments that were restored from a backup but didn't
* have the correct archive state because the archive upload hadn't completed when the backup was made.
*
* @return the number of unique (plaintextHash, remoteKey) pairs that were updated
*/
fun setArchiveFinishedForMatchingMediaObjects(objects: Set<ArchivedMediaObject>): Int {
if (objects.isEmpty()) {
return 0
}
val objectsByMediaId: Map<String, ArchivedMediaObject> = objects.associateBy { it.mediaId }
// Collect updates grouped by CDN: Map<cdn, List<Pair<plaintextHash, remoteKey>>>
val updatesByCdn: MutableMap<Int, MutableList<Pair<String, String>>> = mutableMapOf()
readableDatabase
.select(DATA_HASH_END, REMOTE_KEY)
.from(TABLE_NAME)
.where("$REMOTE_KEY NOT NULL AND $DATA_HASH_END NOT NULL AND $ARCHIVE_TRANSFER_STATE != ?", ArchiveTransferState.FINISHED.value)
.groupBy("$DATA_HASH_END, $REMOTE_KEY")
.run()
.forEach { cursor ->
val remoteKeyStr = cursor.requireNonNullString(REMOTE_KEY)
val plaintextHashStr = cursor.requireNonNullString(DATA_HASH_END)
val remoteKey = Base64.decode(remoteKeyStr)
val plaintextHash = Base64.decode(plaintextHashStr)
val mediaId = MediaName.fromPlaintextHashAndRemoteKey(plaintextHash, remoteKey)
.toMediaId(SignalStore.backup.mediaRootBackupKey)
.encode()
val matchingObject = objectsByMediaId[mediaId]
if (matchingObject != null) {
updatesByCdn.getOrPut(matchingObject.cdn) { mutableListOf() }
.add(plaintextHashStr to remoteKeyStr)
}
}
if (updatesByCdn.isEmpty()) {
return 0
}
var updatedCount = 0
writableDatabase.withinTransaction { db ->
for ((cdn, pairs) in updatesByCdn) {
// Batch updates - each pair uses 2 query args, so chunk accordingly
for (batch in pairs.chunked(500)) {
val whereClause = batch.joinToString(" OR ") { "($DATA_HASH_END = ? AND $REMOTE_KEY = ?)" }
val whereArgs = batch.flatMap { listOf(it.first, it.second) }.toTypedArray()
db.update(TABLE_NAME)
.values(
ARCHIVE_TRANSFER_STATE to ArchiveTransferState.FINISHED.value,
ARCHIVE_CDN to cdn
)
.where(whereClause, *whereArgs)
.run()
updatedCount += batch.size
}
}
}
if (updatedCount > 0) {
AppDependencies.databaseObserver.notifyAttachmentUpdatedObservers()
}
return updatedCount
}
fun clearArchiveData(attachmentId: AttachmentId) {
writableDatabase
.update(TABLE_NAME)

View File

@@ -352,6 +352,9 @@ class ArchiveAttachmentReconciliationJob private constructor(
* Deletes attachments from the archive CDN, after verifying that they also can't be found anywhere in [org.thoughtcrime.securesms.database.AttachmentTable]
* either. Checking the attachment table is very expensive and independent of query size, which is why we batch the lookups.
*
* Also fixes archive transfer state for attachments that ARE found locally but may have incorrect state
* (e.g., restored from a backup before archive upload completed).
*
* @return A non-successful [Result] in the case of failure, otherwise null for success.
*/
private fun validateAndDeleteFromRemote(deletes: Set<ArchivedMediaObject>): Result? {
@@ -360,6 +363,17 @@ class ArchiveAttachmentReconciliationJob private constructor(
Log.d(TAG, "Found that ${validatedDeletes.size}/${deletes.size} requested remote deletes were valid based on current attachment table state.", true)
stopwatch.split("validate")
// Fix archive state for attachments that are found locally but weren't in the latest snapshot.
// This can happen when restoring from a backup that was made before archive upload completed. The files would be uploaded, but no CDN info would be in the backup.
val foundLocally = deletes - validatedDeletes
if (foundLocally.isNotEmpty()) {
val fixedCount = SignalDatabase.attachments.setArchiveFinishedForMatchingMediaObjects(foundLocally)
if (fixedCount > 0) {
Log.i(TAG, "Fixed archive transfer state for $fixedCount attachment groups that were found on CDN but had incorrect local state.", true)
}
}
stopwatch.split("fix-state")
if (validatedDeletes.isEmpty()) {
return null
}

View File

@@ -359,7 +359,7 @@ class RestoreAttachmentJob private constructor(
}
if (useArchiveCdn && attachment.archiveTransferState != AttachmentTable.ArchiveTransferState.FINISHED) {
throw InvalidAttachmentException("[$attachmentId] Invalid attachment configuration! backsUpMedia: ${SignalStore.backup.backsUpMedia}, forceTransitTier: $forceTransitTier, archiveTransferState: ${attachment.archiveTransferState}")
Log.w(TAG, "[$attachmentId] Archive state was not FINISHED, but we backup media and have a dataHash, so we should try anyway. archiveTransferState: ${attachment.archiveTransferState}")
}
val messageReceiver = AppDependencies.signalServiceMessageReceiver