diff --git a/app/src/main/java/app/closer/domain/model/ConversationBackup.kt b/app/src/main/java/app/closer/domain/model/ConversationBackup.kt new file mode 100644 index 00000000..20530a7a --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/ConversationBackup.kt @@ -0,0 +1,84 @@ +package app.closer.domain.model + +/** + * Domain models for the E2EE conversation backup & restore system. + * + * The backup is a couple-key-encrypted snapshot of the couple's conversation history (messages + + * media refs) so a new/wiped device can restore it, and either partner can restore for the other. + * See the plan + `docs/Engineering_Reference_Manual.md` (backup/restore). + */ + +/** Current backup wire-format version. Bump when the record/manifest shape changes. */ +const val BACKUP_SCHEMA_VERSION = 1 + +/** + * One backed-up message. The `text` field keeps the message's existing `enc:v1:` value **verbatim** + * (no decrypt/re-encrypt on backup) — the whole chunk is then wrapped in a couple-key `enc:v1:` + * envelope, so metadata (sender/timestamp/type) is also ciphertext at rest. + */ +data class BackupMessageRecord( + val messageId: String, + val conversationId: String, + val conversationType: String = "main", + val authorUserId: String = "", + val type: String = "text", // text | image | voice + val encText: String? = null, // existing enc:v1: body for text messages + val mediaUrl: String? = null, // tokenized Storage URL for image/voice + val durationMs: Long? = null, + val createdAt: Long = 0L, // resolved server timestamp (millis) + val deleted: Boolean = false, + val reactions: Map = emptyMap() +) + +/** + * A cursor into the message stream: incremental backup appends everything strictly after this point. + * Ordered by (createdAt, messageId) so ties are stable across both partners' devices. + */ +data class BackupCursor( + val createdAt: Long = 0L, + val messageId: String = "" +) { + fun isAfter(other: BackupCursor): Boolean = + createdAt != other.createdAt && createdAt > other.createdAt || + (createdAt == other.createdAt && messageId > other.messageId) + + companion object { + val ZERO = BackupCursor(0L, "") + } +} + +/** + * Backup manifest (`couples/{id}/backup/manifest`). `generation` drives optimistic concurrency so the + * two partners' devices can both write without clobbering each other (CAS in a transaction). + */ +data class BackupManifest( + val schemaVersion: Int = BACKUP_SCHEMA_VERSION, + val generation: Long = 0L, + val snapshotUrl: String? = null, + val snapshotOwner: String = "", // uid whose Storage path holds the snapshot (for targeted cleanup) + val snapshotChecksum: String? = null, + val snapshotThroughCursor: BackupCursor = BackupCursor.ZERO, + val latestChunkSeq: Long = 0L, + val messageCount: Int = 0, // for the "Last backed up: N messages" indicator + rollback cross-check + val updatedAt: Long = 0L, + val updatedBy: String = "" +) + +/** Lifecycle of a partner-assisted restore request (`couples/{id}/restore_requests/{recipientUid}`). */ +enum class RestoreStatus { REQUESTED, READY, RESTORED, DECLINED, EXPIRED } + +/** + * A recovering device (the recipient) asks its partner to re-share the couple key + refresh the backup. + * The partner writes [keybox] (the couple key ECIES-wrapped to [recipientPublicKey]) after confirming + * the out-of-band [verificationCode]. Never contains plaintext key material readable by the server. + */ +data class RestoreRequest( + val recipientUid: String = "", + val recipientPublicKey: String = "", // pub:v1:... + val requestNonce: String = "", + val keybox: String? = null, // keybox:v1:... (partner-written) + val status: RestoreStatus = RestoreStatus.REQUESTED, + val createdAt: Long = 0L, + val expiresAt: Long = 0L, + val fulfilledAt: Long? = null +)