diff --git a/app/src/main/java/app/closer/data/backup/BackupManager.kt b/app/src/main/java/app/closer/data/backup/BackupManager.kt new file mode 100644 index 00000000..05707b59 --- /dev/null +++ b/app/src/main/java/app/closer/data/backup/BackupManager.kt @@ -0,0 +1,114 @@ +package app.closer.data.backup + +import android.util.Log +import app.closer.crypto.CoupleEncryptionManager +import app.closer.data.remote.FirestoreBackupDataSource +import app.closer.data.remote.FirestoreConversationDataSource +import app.closer.domain.model.BackupCursor +import app.closer.domain.model.BackupManifest +import app.closer.domain.model.BackupMessageRecord +import app.closer.domain.repository.AuthRepository +import app.closer.domain.repository.CoupleRepository +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Orchestrates the E2EE conversation backup: cheap **incremental** appends (new messages since the + * manifest cursor) plus periodic **compaction** into a full snapshot. Compaction re-reads current + * message state, so it also captures mutations (deletes/reactions) that a pure append-log would miss. + * + * Both partners run this against the same couple backup; convergence is by message-id dedupe (restore) + * + manifest `generation` CAS (here). All content is couple-key ciphertext — this class never logs it. + */ +@Singleton +class BackupManager @Inject constructor( + private val authRepository: AuthRepository, + private val coupleRepository: CoupleRepository, + private val encryptionManager: CoupleEncryptionManager, + private val conversationDataSource: FirestoreConversationDataSource, + private val backupDataSource: FirestoreBackupDataSource +) { + private val mutex = Mutex() + @Volatile private var lastRunAt = 0L + + private val cursorComparator = Comparator { a, b -> + if (a.createdAt != b.createdAt) a.createdAt.compareTo(b.createdAt) else a.messageId.compareTo(b.messageId) + } + + /** + * Opportunistic backup — safe to call frequently (e.g. app background / Home load). Throttled and + * single-flighted; a missing couple key or no couple is a graceful no-op. Never throws. + */ + suspend fun backupNow(force: Boolean = false) { + val now = System.currentTimeMillis() + if (!force && now - lastRunAt < MIN_INTERVAL_MS) return + if (mutex.isLocked) return + mutex.withLock { + runCatching { + val uid = authRepository.currentUserId ?: return + val couple = coupleRepository.getCoupleForUser(uid) ?: return + val coupleId = couple.id + if (encryptionManager.aeadFor(coupleId) == null) return // key not present on this device + lastRunAt = now + + appendIncremental(coupleId, uid) + + val manifest = backupDataSource.getManifest(coupleId) + val chunkCount = backupDataSource.getChunks(coupleId).size + if (manifest?.snapshotUrl == null || chunkCount >= COMPACT_CHUNK_THRESHOLD) { + compact(coupleId, uid) + } + }.onFailure { Log.w(TAG, "backup run failed", it) } + } + } + + /** Reads new messages since the backed-up cursor and appends them as one encrypted chunk. */ + private suspend fun appendIncremental(coupleId: String, uid: String) { + repeat(MAX_CAS_RETRIES) { + val manifest = backupDataSource.getManifest(coupleId) ?: BackupManifest() + val cursor = manifest.snapshotThroughCursor + val records = collectRecords(coupleId, afterCreatedAt = cursor.createdAt) + .filter { cursorComparator.compare(BackupCursor(it.createdAt, it.messageId), cursor) > 0 } + if (records.isEmpty()) return + val newCursor = records.map { BackupCursor(it.createdAt, it.messageId) }.maxWith(cursorComparator) + if (backupDataSource.appendChunk(coupleId, uid, records, newCursor, records.size)) return + // CAS lost (partner appended concurrently) → re-read + retry. + } + } + + /** Full-state re-read → single snapshot blob → fold chunks. Captures deletes/reactions. */ + private suspend fun compact(coupleId: String, uid: String) { + repeat(MAX_CAS_RETRIES) { + val manifest = backupDataSource.getManifest(coupleId) ?: BackupManifest() + val records = collectRecords(coupleId, afterCreatedAt = 0L) + if (records.isEmpty() && manifest.snapshotUrl == null) return // nothing to snapshot yet + val through = records.map { BackupCursor(it.createdAt, it.messageId) } + .maxWithOrNull(cursorComparator) ?: BackupCursor.ZERO + val foldedSeqs = backupDataSource.getChunks(coupleId).map { it.seq } + val result = backupDataSource.writeSnapshot( + coupleId = coupleId, + userId = uid, + records = records, + throughCursor = through, + expectedGeneration = manifest.generation, + foldedChunkSeqs = foldedSeqs + ) + if (result != null) return // committed + // CAS lost → re-read + retry. + } + } + + private suspend fun collectRecords(coupleId: String, afterCreatedAt: Long): List = + conversationDataSource.getConversationsForBackup(coupleId).flatMap { (id, type) -> + conversationDataSource.getBackupRecords(coupleId, id, type, afterCreatedAt) + } + + private companion object { + const val TAG = "BackupManager" + const val MIN_INTERVAL_MS = 5 * 60 * 1000L // throttle opportunistic runs to ~once per 5 min + const val COMPACT_CHUNK_THRESHOLD = 15 // fold into a snapshot once enough chunks accrue + const val MAX_CAS_RETRIES = 4 + } +} diff --git a/app/src/main/java/app/closer/data/backup/BackupRestoreManager.kt b/app/src/main/java/app/closer/data/backup/BackupRestoreManager.kt new file mode 100644 index 00000000..63e029ff --- /dev/null +++ b/app/src/main/java/app/closer/data/backup/BackupRestoreManager.kt @@ -0,0 +1,95 @@ +package app.closer.data.backup + +import android.util.Log +import app.closer.crypto.CoupleEncryptionManager +import app.closer.data.local.ConversationCacheDao +import app.closer.data.local.entity.ConversationCacheEntity +import app.closer.data.remote.BackupCodec +import app.closer.data.remote.FirestoreBackupDataSource +import app.closer.domain.model.BackupMessageRecord +import app.closer.domain.repository.AuthRepository +import app.closer.domain.repository.CoupleRepository +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Restores the couple's conversation history from the E2EE backup into the local durable cache. + * + * Requires the couple key to already be present (recovered via the phrase or partner-assist). The + * snapshot blob + incremental chunks are decrypted with the couple key, merged (chunks override the + * snapshot per message id), integrity-checked, and upserted into [ConversationCacheDao] (idempotent → + * safe to re-run / resume). Content is never logged. + */ +@Singleton +class BackupRestoreManager @Inject constructor( + private val authRepository: AuthRepository, + private val coupleRepository: CoupleRepository, + private val encryptionManager: CoupleEncryptionManager, + private val backupDataSource: FirestoreBackupDataSource, + private val cacheDao: ConversationCacheDao +) { + + sealed interface RestoreResult { + data class Success(val restored: Int, val manifestMessageCount: Int) : RestoreResult + data object NothingToRestore : RestoreResult + data class Unavailable(val reason: String) : RestoreResult // no key / no couple + data class Failed(val reason: String) : RestoreResult + } + + suspend fun restore(): RestoreResult { + val uid = authRepository.currentUserId ?: return RestoreResult.Unavailable("not signed in") + val couple = coupleRepository.getCoupleForUser(uid) ?: return RestoreResult.Unavailable("no couple") + val coupleId = couple.id + if (encryptionManager.aeadFor(coupleId) == null) return RestoreResult.Unavailable("couple key missing") + + return runCatching { + val manifest = backupDataSource.getManifest(coupleId) ?: return RestoreResult.NothingToRestore + + val byId = LinkedHashMap() + + // 1) Snapshot (full current state), integrity-checked. + manifest.snapshotUrl?.let { url -> + val ciphertext = backupDataSource.downloadSnapshotCiphertext(url) + val records = backupDataSource.decodeCiphertext(coupleId, ciphertext) + val plainForChecksum = BackupCodec.encode(records) + if (manifest.snapshotChecksum != null && manifest.snapshotChecksum != BackupCodec.checksum(plainForChecksum)) { + // Corruption/tamper or a partial write — skip the snapshot, still restore from chunks. + Log.w(TAG, "snapshot checksum mismatch — restoring from chunks only") + } else { + records.forEach { byId[it.messageId] = it } + } + } + + // 2) Incremental chunks (newer messages) override the snapshot per id. + backupDataSource.getChunks(coupleId).forEach { chunk -> + backupDataSource.decodeCiphertext(coupleId, chunk.payload).forEach { byId[it.messageId] = it } + } + + if (byId.isEmpty()) return RestoreResult.NothingToRestore + + cacheDao.upsertAll(byId.values.map { it.toEntity() }) + RestoreResult.Success(restored = byId.size, manifestMessageCount = manifest.messageCount) + }.getOrElse { + Log.w(TAG, "restore failed", it) + RestoreResult.Failed(it.message ?: "restore failed") + } + } + + private fun BackupMessageRecord.toEntity() = ConversationCacheEntity( + messageId = messageId, + conversationId = conversationId, + conversationType = conversationType, + authorUserId = authorUserId, + type = type, + encText = encText, + mediaUrl = mediaUrl, + durationMs = durationMs, + createdAt = createdAt, + deleted = deleted, + reactionsJson = BackupCodec.reactionsToJson(reactions) + ) + + private companion object { + const val TAG = "BackupRestoreManager" + } +}