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
7 changes: 7 additions & 0 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2121,6 +2121,13 @@ final class ConversationController {
assertion.begin()
defer { assertion.end() }

// A chip that failed retryably (offline, say) is sendable; tapping Send is its retry.
for chip in chips {
if case .failed(.retryable) = chip.state {
chip.retryUpload()
}
}

let sends = ChatMediaSendPlan.mediaMessages(chips: chips, caption: caption, replyTo: repliedTo).map { message in
(message: message, clientMessageID: insertPendingMedia(message, into: conversationID))
}
Expand Down
7 changes: 4 additions & 3 deletions Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,9 @@ enum ChatMediaUploadError: Error {
case .noMatchingConstraint, .encryptionNotAllowed, .encodingFailed:
false
case .sealUnavailable(let error):
// A peer key that couldn't be fetched may arrive; one shared-core refuses never will.
error is PeerKeyUnavailable
// A peer key that couldn't be fetched may arrive, as may a chat lost to the network
// (transport failures classify as suppressed); one shared-core refuses never will.
error is PeerKeyUnavailable || (error as? ServerError)?.reportingLevel == .suppressed
case .rejected(let reason):
switch reason {
case .moderation:
Expand All @@ -125,7 +126,7 @@ extension ChatMediaUploadError: ServerError {
case .noMatchingConstraint, .encryptionNotAllowed, .encodingFailed:
.error
case .sealUnavailable(let error):
error is PeerKeyUnavailable ? .info : .error
error is PeerKeyUnavailable ? .info : (error as? ServerError)?.reportingLevel ?? .error
case .rejected:
.info
case .failed(let error):
Expand Down
12 changes: 7 additions & 5 deletions Flipcash/Core/Screens/Conversation/ComposerModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,9 @@ final class ComposerModel {
}
}

/// Whether the send button can fire: text or at least one chip, and no chip that failed. Chips
/// still uploading do not hold the send back, and an edit is text only.
/// Whether the send button can fire: text or at least one chip, and no chip that failed for
/// good. Chips still uploading or that failed retryably do not hold the send back, and an edit
/// is text only.
var canSubmit: Bool {
switch mode {
case .new, .replying:
Expand Down Expand Up @@ -104,12 +105,13 @@ final class ComposerModel {
}
}

/// Whether any staged chip failed to upload, which holds the send until it is retried or removed.
/// Whether any staged chip failed in a way another upload can't fix, which holds the send
/// until it is removed.
var hasFailedChip: Bool {
chips.contains { chip in
switch chip.state {
case .failed: true
case .preparing, .uploading, .uploaded: false
case .failed(.notRetryable): true
case .failed(.retryable), .preparing, .uploading, .uploaded: false
}
}
}
Expand Down
18 changes: 15 additions & 3 deletions FlipcashCore/Sources/FlipcashCore/Blob/BlobUploader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,14 +137,19 @@ final class BlobUploader: Sendable {
/// The MIME type every end-to-end encrypted upload declares; the server refuses any other.
static let encryptedMimeType = "application/octet-stream"

/// How many finalization polls may be lost in transit back to back before the wait gives up.
static let maxConsecutiveLostPolls = 3

/// Polls until the blob is finalized.
///
/// Returns immediately when it is already ready; a rejection is terminal. A poll lost in transit
/// is retried, since iOS drops the connection when it suspends the app mid-wait. The `timeout`
/// budget is counted in polls rather than wall time, so time spent suspended doesn't use it up.
/// is retried, since iOS drops the connection when it suspends the app mid-wait, but
/// ``maxConsecutiveLostPolls`` in a row throw `ErrorBlob.network`. The `timeout` budget is
/// counted in polls rather than wall time, so time spent suspended doesn't use it up.
func awaitFinalization(blobID: BlobID, owner: KeyPair) async throws {
let maxPolls = max(1, Int((timeout / pollInterval).rounded(.up)))
var polls = 0
var lostInARow = 0

while true {
try Task.checkCancellation()
Expand All @@ -161,14 +166,21 @@ final class BlobUploader: Sendable {
])
throw ErrorBlob.rejected(reason)
case .pending, .processing:
break
lostInARow = 0
}
} catch ErrorBlob.network(let error) {
try Task.checkCancellation()
lostInARow += 1
logger.info("Blob poll lost in transit", metadata: [
"blobId": "\(blobID)",
"error": "\(error)",
"lostInARow": "\(lostInARow)",
])
// Offline, each poll waits out its RPC deadline, so the poll budget alone would
// hold the send for minutes.
if lostInARow >= Self.maxConsecutiveLostPolls {
throw ErrorBlob.network(error)
}
}

guard polls < maxPolls else {
Expand Down
10 changes: 10 additions & 0 deletions FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,16 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable {
}
}

/// Whether a double tap on this row's bubble brings up the reaction strip on its own. Cash and
/// profile cards and bare link cards keep their instant single tap instead.
public var takesDoubleTapReaction: Bool {
guard offersReactionStrip, !rendersAsBareLinkCard else { return false }
switch content {
case .text, .media: return true
case .cash, .deleted, .unavailable, .shareProfile: return false
}
}

public init(
id: String,
serverID: MessageID? = nil,
Expand Down
50 changes: 45 additions & 5 deletions FlipcashCore/Tests/FlipcashCoreTests/BlobUploaderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,8 @@ struct BlobUploaderTests {

@Test("Polls lost in transit still count toward the deadline")
func lostPollsHonourTheDeadline() async throws {
let reserving = StubReserving(states: [], lostPolls: .max)
// Never three in a row, so only the overall budget ends the wait.
let reserving = StubReserving(states: [], lostAt: [1, 2, 4, 5])
let uploader = BlobUploader(
reserving: reserving,
transport: RecordingTransport(),
Expand All @@ -413,6 +414,42 @@ struct BlobUploaderTests {
#expect(await reserving.pollCount == 5)
}

/// Offline, every poll waits out its RPC deadline, so the overall budget would hold the row at
/// Sending for minutes.
@Test("Three polls lost in a row give up as a network failure, well inside the budget")
func consecutiveLostPollsFailEarly() async throws {
let reserving = StubReserving(states: [], lostPolls: .max)
let uploader = BlobUploader(
reserving: reserving,
transport: RecordingTransport(),
pollInterval: .milliseconds(1),
timeout: .seconds(5)
)

await #expect {
try await uploader.awaitFinalization(blobID: StubReserving.blobID, owner: try Self.owner())
} throws: { error in
guard case ErrorBlob.network = error else { return false }
return true
}
#expect(await reserving.pollCount == 3)
}

@Test("A poll that gets through starts the lost-poll count again")
func answeredPollResetsLostStreak() async throws {
let reserving = StubReserving(states: [.processing, .processing, .ready], lostAt: [1, 2, 4, 5])
let uploader = BlobUploader(
reserving: reserving,
transport: RecordingTransport(),
pollInterval: .milliseconds(1),
timeout: .seconds(5)
)

try await uploader.awaitFinalization(blobID: StubReserving.blobID, owner: try Self.owner())

#expect(await reserving.pollCount == 7)
}

// MARK: - Fixtures -

private static let fileBytes = Data(repeating: 0xAB, count: 4096)
Expand Down Expand Up @@ -573,17 +610,20 @@ private actor StubReserving: BlobReserving {
private var states: [BlobState]
private let completion: BlobState
private var lostPolls: Int
private let lostAt: Set<Int>
private(set) var pollCount = 0
private(set) var reserveCount = 0
private(set) var declaredSizeBytes: Int?
private(set) var declaredMimeType: String?
private(set) var encryptedFor: ConversationID?

/// `lostPolls` is how many polls fail in transit before the stub starts answering.
init(states: [BlobState], completion: BlobState = .processing, lostPolls: Int = 0) {
/// `lostPolls` is how many polls fail in transit before the stub starts answering; `lostAt`
/// names further polls, counted from 1, that fail in transit.
init(states: [BlobState], completion: BlobState = .processing, lostPolls: Int = 0, lostAt: Set<Int> = []) {
self.states = states
self.completion = completion
self.lostPolls = lostPolls
self.lostAt = lostAt
}

func initiateExternalUpload(mimeType: String, sizeBytes: Int, encryptedFor: ConversationID?, owner: KeyPair) async throws -> ReservedUpload {
Expand Down Expand Up @@ -614,8 +654,8 @@ private actor StubReserving: BlobReserving {

func blobState(blobID: BlobID, owner: KeyPair) async throws -> BlobState {
pollCount += 1
if lostPolls > 0 {
lostPolls -= 1
if lostPolls > 0 || lostAt.contains(pollCount) {
lostPolls = max(0, lostPolls - 1)
throw ErrorBlob.network(URLError(.networkConnectionLost))
}
return states.isEmpty ? .processing : states.removeFirst()
Expand Down
24 changes: 24 additions & 0 deletions FlipcashTests/Chat/ChatMediaUploaderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,30 @@ struct ChatMediaUploaderTests {
#expect(blob.storeAttempts == 0)
}

@Test("A chat that couldn't be fetched offline leaves the photo retryable, and reports nothing")
func offlineChatFetchIsRetryable() async {
let blob = MockChatMediaBlobStore()
var uploader = ChatMediaUploader(blob: blob)
uploader.seal = { throw ErrorGetChat.transportFailure }

let error = await Self.uploadError(uploader)

#expect(error?.isRetryable == true)
#expect(error?.isTerminal == false)
#expect(error?.reportingLevel == .suppressed)
#expect(blob.storeAttempts == 0)
}

@Test("A chat the server refuses to return leaves the photo unretryable")
func deniedChatFetchIsNotRetryable() async {
var uploader = ChatMediaUploader(blob: MockChatMediaBlobStore())
uploader.seal = { throw ErrorGetChat.denied }

let error = await Self.uploadError(uploader)

#expect(error?.isRetryable == false)
}

// MARK: - Gate -

@Test("Camera and Photos show in an encrypted DM, a plaintext chat, and not before the chat loads")
Expand Down
4 changes: 2 additions & 2 deletions FlipcashTests/Chat/ChatPhotoStagingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -305,12 +305,12 @@ struct ComposerOutgoingTests {
}
}

@Test("A failed chip holds the send")
@Test("A chip that failed for good holds the send")
func failedChipHoldsSend() throws {
let composer = ComposerModel()
let chip = try #require(composer.stageChip(image: image(), uploader: uploader))
chip.uploadTask?.cancel()
chip.state = .failed(.retryable)
chip.state = .failed(.notRetryable)
composer.draft = "caption"

#expect(composer.outgoing == nil)
Expand Down
21 changes: 18 additions & 3 deletions FlipcashTests/Chat/ComposerChipTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,19 +91,34 @@ struct ComposerChipTests {
#expect(composer.canSubmit)
}

@Test("A chip that failed holds the send back until it is removed")
func failedChipDisablesSend() throws {
@Test("A chip that failed for good holds the send back until it is removed")
func unretryableChipDisablesSend() throws {
let composer = ComposerModel()
composer.draft = "look"
let chip = try #require(composer.stageChip(image: testImage(), uploader: uploader))
chip.state = .failed(.retryable)
chip.state = .failed(.notRetryable)

#expect(!composer.canSubmit)

composer.removeChip(chip.id)
#expect(composer.canSubmit)
}

@Test("A chip whose upload failed offline can still be sent")
func retryableChipStillSends() throws {
let composer = ComposerModel()
composer.draft = "look"
let chip = try #require(composer.stageChip(image: testImage(), uploader: uploader))
chip.state = .failed(.retryable)

#expect(composer.canSubmit)
guard case .media(let chips, caption: "look") = composer.outgoing else {
Issue.record("expected the chip to go out as media")
return
}
#expect(chips.map(\.id) == [chip.id])
}

@Test("Clearing after a send empties both the text and the chips")
func clearEmptiesChips() throws {
let composer = ComposerModel()
Expand Down
35 changes: 35 additions & 0 deletions FlipcashTests/Chat/ConversationMediaSendTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,41 @@ struct ConversationMediaSendTests {
#expect(chip.progress.phase == .sent, "the retried upload brings the overlay back and ends it sent")
}

@Test("Sending a photo whose upload already failed offline uploads it again and posts it")
func sendRestartsFailedUpload() async throws {
let mock = MockConversations()
let controller = makeController(mock)
let blob = MockChatMediaBlobStore()
blob.storeResults = [.failure(ErrorBlob.network(URLError(.notConnectedToInternet)))]
let chip = ComposerChip(image: testImage())
chip.startUpload(using: ChatMediaUploader(blob: blob, backoff: []))
_ = try? await chip.uploadTask?.value
#expect(chip.state == .failed(.retryable))

let sent = await controller.sendMedia([chip], caption: nil, to: conversationID)

#expect(sent)
#expect(blob.storeAttempts == 2)
#expect(mock.sentMedia.map(\.blobID) == [MockChatMediaBlobStore.blobID])
#expect(pendingMedia(controller).isEmpty)
}

@Test("Sending a photo that failed for good does not upload it again")
func sendLeavesUnretryableFailure() async throws {
let mock = MockConversations()
let controller = makeController(mock)
let blob = MockChatMediaBlobStore()
blob.finalization = .failure(ErrorBlob.rejected(.moderation))
let chip = ComposerChip(image: testImage())
chip.startUpload(using: ChatMediaUploader(blob: blob, backoff: []))
_ = try? await chip.uploadTask?.value
#expect(chip.state == .failed(.notRetryable))

#expect(!(await controller.sendMedia([chip], caption: nil, to: conversationID)))
#expect(blob.storeAttempts == 1)
#expect(pendingMedia(controller).map(\.status) == [.failed])
}

@Test("A photo refused by moderation is not retried")
func moderationIsNotRetried() async throws {
let mock = MockConversations()
Expand Down
14 changes: 14 additions & 0 deletions FlipcashTests/Chat/ReactionUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ struct ReactionUITests {
#expect(message.offersReactionStrip == !isRedacted)
}

@Test("Double-tapping a photo or a text bubble brings up the strip")
func photoAndTextTakeDoubleTap() {
let media = ChatMediaContent(blobID: nil, width: 100, height: 100, blurhash: nil, caption: nil, isRedacted: false)
#expect(Self.makeMessage(content: .media(media), sender: .other).takesDoubleTapReaction)
#expect(Self.makeMessage(content: .text("hi"), sender: .other).takesDoubleTapReaction)
}

@Test("A redacted photo or a deleted message takes no double tap")
func ineligibleRowsTakeNoDoubleTap() {
let redacted = ChatMediaContent(blobID: nil, width: 100, height: 100, blurhash: nil, caption: nil, isRedacted: true)
#expect(!Self.makeMessage(content: .media(redacted), sender: .other).takesDoubleTapReaction)
#expect(!Self.makeMessage(content: .deleted("This message was deleted"), sender: .other).takesDoubleTapReaction)
}

@Test("The viewer's own message still in flight never offers the strip")
func inFlightSendNoStrip() {
let message = Self.makeMessage(content: .text("hi"), sender: .me, receipt: nil, isUnsent: true)
Expand Down
Loading
Loading