Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .claude/plans/2026-10-03-pending-photo-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Persist pending chat photo sends

## Problem

A pending photo send lives only in memory. That's `ConversationController.pendingMediaChips` (`ComposerChip`, holding a `UIImage`) plus the in-memory `ConversationStore.pendingByConversation` row. If iOS kills the app in the background, the photo and its bubble are both lost.

On-device evidence, 2026-10-03 15:39–15:45:
- blob `51993f1f…` was reserved,
- the app was backgrounded 3 s later,
- the next log line is a cold launch at 15:45:07,
- the chat showed no photo and no failed bubble.

## What Android does (text only — Android has no media send)

- Pending rows are stored in the same Room table as messages, with status `SENDING`/`SENT`/`FAILED`.
- At login, every `SENDING` row is marked `FAILED`, and the user taps retry.
- A retry reuses the same client id, and the server dedupes on `client_message_id`.

## Design

### Storage
- Use a JSON manifest plus one JPEG file per photo, following `ChatDraftStore`'s precedent and reasoning.
- `FlipcashStore` (SQLite) is the wrong home because its versioning is destructive, and an outbox can't be re-fetched.
- Location: Application Support, scoped to the owner:
- manifest `flipcash-<owner>-pending-media.json`,
- photos in `flipcash-<owner>-pending-media/<clientMessageID>.jpg`.
- Leave the files at the default protection class, `completeUntilFirstUserAuthentication`.

### Entry
Each entry holds:
- `clientMessageID`
- `conversationID`
- `createdAt`
- the image file name
- `caption?` and `replyTo?`, exactly as `ChatMediaSendPlan` assigned them to this message
- `stored: UploadedPhoto?`

`stored` is set the moment `store` returns. `SealedPhoto` is metadata only (blob id, MIME type, size, dimensions, blurhash), so it's safe to persist, and it needs `Codable`.

### Encode once
- Encode the JPEG before the upload starts and write it to disk then. A kill mid-upload must not lose the photo.
- The upload then sends those same bytes. The retry path never re-encodes a re-decoded image.

### Lifecycle
- Write the entry when `sendMedia` runs. Attached-but-unsent composer chips are not persisted.
- Update `stored` when the bytes land.
- Delete the entry and its file at the same point `dropPending` runs: after `upsertConversationMessages` succeeds, never before.
- Also delete when the user discards a failed row, and when the failure can't be retried (`.rejected`).

### Launch
In `ConversationController.start()`, after hydrate:
- Load the manifest.
- Sweep orphans: files without an entry, and entries without a file.
- Re-insert each entry as a pending row, anchored by its `createdAt` rather than `newestMessageID`, and rebuild its chip from the file.
- `stored == nil`: show the row as failed with retry, as Android does. Retry uploads the file.
- `stored != nil`: resume the READY poll automatically, then send.

### Dedupe
- The risk is a kill after `sendMediaMessage` succeeds but before the entry is deleted.
- Before re-inserting an entry that has `stored`, look for a self-authored media message with that blob id in the loaded messages. If one exists, the photo already sent, so drop the entry.
- An entry with no blob id can't exist on the server, so it's always safe to re-insert.

## Tests
- The store round-trips its entries and sweeps orphans.
- Reconciliation drops an entry whose blob id is in the loaded messages and keeps one whose blob id isn't.
- At launch, `stored == nil` comes back as failed and `stored != nil` resumes.

## Known gaps
- An end-to-end encrypted photo sits as a plaintext JPEG in the app's sandbox until it sends.
- `pendingMatch`'s date window: a resumed send's row keeps its old `createdAt`, so the stream echo may not match it. The send RPC's own confirm still removes the row.
217 changes: 200 additions & 17 deletions Flipcash/Core/Controllers/ConversationController.swift

Large diffs are not rendered by default.

66 changes: 63 additions & 3 deletions Flipcash/Core/Controllers/EncryptedChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import Foundation
import os
import FlipcashCore

private let logger = Logger(label: "flipcash.encrypted-chat")

/// The chat surface `ConversationController` talks to, with DM end-to-end encryption applied at
/// the edge: every message read from the server comes back decrypted (or marked with why it
/// couldn't be), and every text sent into an encrypting chat goes out sealed.
Expand Down Expand Up @@ -141,9 +143,67 @@ extension EncryptedChatClient: ConversationMessaging {
}
}

/// Media has no sealed form, so a photo goes out in plaintext whatever the chat's encryption.
func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, blobID: BlobID, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage {
try await client.sendMediaMessage(owner: owner, conversationID: conversationID, blobID: blobID, caption: caption, repliedTo: repliedTo, clientMessageID: clientMessageID)
/// Sends a photo the way it was uploaded, after checking the chat still decides the same way: a
/// plaintext blob never goes into a chat that encrypts, nor a sealed one into a chat that doesn't.
func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, photo: UploadedPhoto, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage {
try await refetchingOnRefusal(conversationID) {
let seal = try await sealForSending(conversationID, owner: owner)
switch Self.photoSend(photo, seal: seal, conversationID: conversationID) {
case .plain(let blobID):
return try await client.sendMediaMessage(owner: owner, conversationID: conversationID, blobID: blobID, caption: caption, repliedTo: repliedTo, clientMessageID: clientMessageID)
case .sealed(let sealed, let seal):
return try await client.sendSealedMediaMessage(owner: owner, conversationID: conversationID, photo: sealed, caption: caption, repliedTo: repliedTo, seal: seal, clientMessageID: clientMessageID)
case .mismatch:
logger.error("Photo was uploaded for a different encryption choice", metadata: [
"conversationID": "\(conversationID)",
"sealed": "\(seal != nil)",
])
throw ErrorSendMessage.encryptionFailed
}
}
}

/// How an uploaded photo goes out given the chat's current seal.
enum PhotoSend: Equatable {
/// Plaintext, into a chat that doesn't encrypt.
case plain(BlobID)
/// Sealed, into the chat it was encrypted for.
case sealed(SealedPhoto, ChatSeal)
/// The photo was uploaded for a different encryption choice than the chat now makes.
case mismatch

static func == (lhs: PhotoSend, rhs: PhotoSend) -> Bool {
switch (lhs, rhs) {
case (.plain(let a), .plain(let b)): a == b
case (.sealed(let a, _), .sealed(let b, _)): a == b
case (.mismatch, .mismatch): true
case (.plain, _), (.sealed, _), (.mismatch, _): false
}
}
}

/// Matches `photo` to `seal`: a plaintext blob never goes into a chat that encrypts, nor a
/// sealed one into a chat that doesn't or a different chat.
static func photoSend(_ photo: UploadedPhoto, seal: ChatSeal?, conversationID: ConversationID) -> PhotoSend {
switch (photo, seal) {
case (.plain(let blobID), nil):
.plain(blobID)
case (.sealed(let sealed), let seal?) where seal.conversationID == conversationID:
.sealed(sealed, seal)
case (.plain, _?), (.sealed, _):
.mismatch
}
}

func photoSeal(owner: KeyPair, conversationID: ConversationID) async throws -> ChatSeal? {
try await sealForSending(conversationID, owner: owner)
}

func openingSeal(owner: KeyPair, conversationID: ConversationID) async -> ChatSeal? {
switch await opener(conversationID, owner: owner) {
case .seal(let seal): seal
case .awaitingKey, .unsupported: nil
}
}

func editMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, text: String, repliedTo: MessageID?, expectedEventSequence: UInt64) async throws -> MessageMutation {
Expand Down
11 changes: 9 additions & 2 deletions Flipcash/Core/Controllers/FlipClient+Protocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,15 @@ protocol ConversationMessaging: AnyObject, Sendable {
onBatch: @MainActor @Sendable @escaping (_ messages: [ConversationMessage], _ checkpoint: UInt64?) -> Void
) async throws -> UInt64
func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage
/// Sends the finalized photo `blobID` as one media message, with `caption` under it.
func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, blobID: BlobID, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage
/// Sends the finalized `photo` as one media message, with `caption` under it; a sealed photo
/// goes out inside `EncryptedContent`.
func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, photo: UploadedPhoto, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage
/// The seal a photo sent into `conversationID` is encrypted with, nil when it goes out in
/// plaintext. Throws when the chat encrypts but its seal can't be built.
func photoSeal(owner: KeyPair, conversationID: ConversationID) async throws -> ChatSeal?
/// The seal `conversationID`'s messages open with, nil when it can't be built yet or the chat
/// doesn't encrypt.
func openingSeal(owner: KeyPair, conversationID: ConversationID) async -> ChatSeal?
/// Replaces a message's text, keeping it a reply to `repliedTo` when set. `expectedEventSequence` is the optimistic-concurrency guard: the
/// server applies the edit only if the message still carries that sequence, and reports a
/// conflict with the winning state otherwise.
Expand Down
11 changes: 11 additions & 0 deletions Flipcash/Core/Screens/Conversation/AttachMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import SwiftUI
import FlipcashUI
import FlipcashCore

/// A row of the composer's attach menu.
enum AttachMenuItem: Equatable {
Expand Down Expand Up @@ -208,3 +209,13 @@ extension AttachMenuItem {
}
}
}

/// Decides whether a chat offers Camera and Photos.
enum ChatMediaGate {

/// True for any chat this device has a record of: an encrypted DM's photos are encrypted for it,
/// a plaintext chat's go up in plaintext. False while the record is still loading.
static func acceptsMedia(_ conversation: Conversation?) -> Bool {
conversation != nil
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,8 @@ extension ChatItem {
height: attachment?.height ?? 0,
blurhash: attachment?.blurhash,
caption: caption,
isRedacted: message.redacted
isRedacted: message.redacted,
sealed: attachment?.sealed
))
}

Expand Down Expand Up @@ -446,7 +447,7 @@ extension ChatItem {
stableID: original.stableID,
authorName: authorName,
snippet: ChatQuote.snippet(forText: ChatMediaStrings.quoteSnippet(caption: caption)),
kind: .media(thumbnailBlobID: original.redacted ? nil : attachments.first?.blobID),
kind: .media(thumbnailBlobID: original.redacted ? nil : attachments.first?.blobID, sealed: attachments.first?.sealed),
authorID: original.senderID
)
case .encrypted:
Expand Down
Loading
Loading