From b758cc9cd74be7c613d6307b4fef96dcfe09ccb7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 15:48:34 -0400 Subject: [PATCH] refactor(chat): move photo upload and sealing glue into FlipcashCore ChatMediaUploader, its blob-store seam, UploadedPhoto, the upload error classification, the plaintext/sealed send guard, ImageIO decode, the BlurHash encoder and display-name resolution now live in FlipcashCore so a share extension can link them without FlipcashUI. Upload progress is reported through ChatPhotoUploadProgress, which ChatPhotoSendProgress conforms to, and the contact-name lookup is injected into Conversation.displayName. The app keeps SessionChatMediaBlobStore and a UIImage overload of the uploader. --- .../Controllers/ConversationController.swift | 17 +-- .../Controllers/EncryptedChatClient.swift | 34 +---- .../ChatMediaUploader+UIImage.swift | 51 +++++++ .../Conversation/ChatPhotoStaging.swift | 12 +- .../SessionChatMediaBlobStore.swift | 32 +++++ .../FlipcashCore/Blob/BlurHashEncoder.swift | 129 ++++++++++++++++++ .../Blob}/ChatMediaUploader.swift | 119 +++++++--------- .../FlipcashCore/Blob/ChatPhotoDecoder.swift | 45 ++++++ .../FlipcashCore/Blob/ChatPhotoSend.swift | 40 ++++++ .../Blob/FlipClientChatMediaBlobStore.swift | 37 +++++ .../Conversation+DisplayName.swift | 38 ++++++ .../ChatPhotoDecoderTests.swift | 57 ++++++++ .../ChatPhotoSendTests.swift | 51 +++++++ .../ConversationDisplayNameTests.swift | 62 +++++++++ .../Chat/ChatMediaUploaderTests.swift | 26 ---- .../Chat/ComposerChipLayoutTests.swift | 1 + .../Chat/ChatPhotoSendProgress.swift | 2 + .../Sources/FlipcashUI/Images/BlurHash.swift | 96 +------------ 18 files changed, 602 insertions(+), 247 deletions(-) create mode 100644 Flipcash/Core/Screens/Conversation/ChatMediaUploader+UIImage.swift create mode 100644 Flipcash/Core/Screens/Conversation/SessionChatMediaBlobStore.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Blob/BlurHashEncoder.swift rename {Flipcash/Core/Screens/Conversation => FlipcashCore/Sources/FlipcashCore/Blob}/ChatMediaUploader.swift (80%) create mode 100644 FlipcashCore/Sources/FlipcashCore/Blob/ChatPhotoDecoder.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Blob/ChatPhotoSend.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Blob/FlipClientChatMediaBlobStore.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation+DisplayName.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/ChatPhotoDecoderTests.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/ChatPhotoSendTests.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/ConversationDisplayNameTests.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 3b1d20eb1..3964d77b5 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1571,7 +1571,7 @@ final class ConversationController { /// Counterpart name shown when neither the synced contacts, the feed, nor a /// shared phone number provides one. - nonisolated static let fallbackCounterpartName = "Flipcash User" + nonisolated static let fallbackCounterpartName = Conversation.fallbackCounterpartName /// The counterpart's name for a conversation: the synced contact's /// address-book name, else the server-provided member name from the feed, @@ -1579,20 +1579,7 @@ final class ConversationController { /// Tip DMs skip the contact lookup — their derived ids can never match a /// contact's, so the directory scan is a guaranteed miss. func displayName(for conversation: Conversation) -> String { - // Group chats are named by their server-set title, not a counterpart. - if let title = conversation.title, !title.isEmpty { - return title - } - if conversation.type != .tipDm, let contactName = contactName(for: conversation.id) { - return contactName - } - guard let counterpart = conversation.counterpart(excluding: selfUserID) else { - return Self.fallbackCounterpartName - } - if !counterpart.displayName.isEmpty { - return counterpart.displayName - } - return counterpart.formattedPhoneNumber ?? Self.fallbackCounterpartName + conversation.displayName(selfUserID: selfUserID, contactName: contactName(for:)) } /// The feed row's last-message line: the typing indicator while the diff --git a/Flipcash/Core/Controllers/EncryptedChatClient.swift b/Flipcash/Core/Controllers/EncryptedChatClient.swift index 58eb36f73..4b0b909c1 100644 --- a/Flipcash/Core/Controllers/EncryptedChatClient.swift +++ b/Flipcash/Core/Controllers/EncryptedChatClient.swift @@ -148,7 +148,7 @@ extension EncryptedChatClient: ConversationMessaging { 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) { + switch ChatPhotoSend.matching(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): @@ -163,38 +163,6 @@ extension EncryptedChatClient: ConversationMessaging { } } - /// 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) } diff --git a/Flipcash/Core/Screens/Conversation/ChatMediaUploader+UIImage.swift b/Flipcash/Core/Screens/Conversation/ChatMediaUploader+UIImage.swift new file mode 100644 index 000000000..2aa1c0849 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatMediaUploader+UIImage.swift @@ -0,0 +1,51 @@ +// +// ChatMediaUploader+UIImage.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit +import ImageIO +import FlipcashCore + +extension ChatMediaUploader { + + /// Returns the finalized photo for `image`, throwing `ChatMediaUploadError`; see + /// ``upload(_:orientation:progress:onStored:onPrepared:onEncoded:)``. + func upload( + _ image: UIImage, + progress: (any ChatPhotoUploadProgress)? = nil, + onStored: (UploadedPhoto) -> Void = { _ in }, + onPrepared: (Int, Int) -> Void, + onEncoded: (Data) -> Void = { _ in } + ) async throws -> UploadedPhoto { + guard let cgImage = image.cgImage else { + throw ChatMediaUploadError.encodingFailed(.encodingFailed) + } + return try await upload( + cgImage, + orientation: CGImagePropertyOrientation(image.imageOrientation), + progress: progress, + onStored: onStored, + onPrepared: onPrepared, + onEncoded: onEncoded + ) + } +} + +private extension CGImagePropertyOrientation { + init(_ orientation: UIImage.Orientation) { + switch orientation { + case .up: self = .up + case .upMirrored: self = .upMirrored + case .down: self = .down + case .downMirrored: self = .downMirrored + case .left: self = .left + case .leftMirrored: self = .leftMirrored + case .right: self = .right + case .rightMirrored: self = .rightMirrored + @unknown default: self = .up + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift b/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift index 9d6831cda..42618f3b0 100644 --- a/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift +++ b/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift @@ -125,17 +125,7 @@ enum ChatPhotoStaging { /// layer's contents, and that rotation animates with the cell when a sent photo's bubble lands. @concurrent nonisolated static func decode(_ data: Data) async -> UIImage? { - guard let source = CGImageSourceCreateWithData(data as CFData, nil), - let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], - let width = properties[kCGImagePropertyPixelWidth] as? Int, - let height = properties[kCGImagePropertyPixelHeight] as? Int, - let upright = CGImageSourceCreateThumbnailAtIndex(source, 0, [ - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceThumbnailMaxPixelSize: max(width, height), - ] as CFDictionary) else { - return nil - } + guard let upright = await ChatPhotoDecoder.decode(data) else { return nil } return UIImage(cgImage: upright) } } diff --git a/Flipcash/Core/Screens/Conversation/SessionChatMediaBlobStore.swift b/Flipcash/Core/Screens/Conversation/SessionChatMediaBlobStore.swift new file mode 100644 index 000000000..50fed7128 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/SessionChatMediaBlobStore.swift @@ -0,0 +1,32 @@ +// +// SessionChatMediaBlobStore.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +/// Stores chat photos on behalf of the signed-in owner. +struct SessionChatMediaBlobStore: ChatMediaBlobStoring { + + let session: Session + let flipClient: FlipClient + + func uploadPolicy() async throws -> UploadPolicy { + try await flipClient.uploadPolicy(owner: session.ownerKeyPair) + } + + func storeBlob(_ data: Data, mimeType: String, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) async throws -> BlobID { + try await flipClient.storeBlob(data, mimeType: mimeType, owner: session.ownerKeyPair, onProgress: onProgress) + } + + func storeEncryptedBlob(_ image: Data, seal: ChatSeal, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) async throws -> EncryptedBlobUpload { + try await flipClient.storeEncryptedBlob(image, seal: seal, owner: session.ownerKeyPair, onProgress: onProgress) + } + + func awaitBlobFinalization(blobID: BlobID) async throws { + try await flipClient.awaitBlobFinalization(blobID: blobID, owner: session.ownerKeyPair) + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Blob/BlurHashEncoder.swift b/FlipcashCore/Sources/FlipcashCore/Blob/BlurHashEncoder.swift new file mode 100644 index 000000000..396a10328 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Blob/BlurHashEncoder.swift @@ -0,0 +1,129 @@ +// +// BlurHashEncoder.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import CoreGraphics +import Foundation + +/// The BlurHash encoder, ported from the public-domain reference implementation. It lives here, not +/// beside the decoder in FlipcashUI, so a process that cannot link the UI can hash the photos it +/// uploads. +public enum BlurHashEncoder { + + /// Encodes `image` into a hash with `componentsX` × `componentsY` components (each 1…9), or nil + /// when the image cannot be drawn. + /// + /// The image is sampled at a small fixed size first: a hash keeps only the lowest frequencies, so + /// more pixels change nothing but the cost. Squashing it to a square is harmless for the same + /// reason; the decoder stretches back to whatever aspect it is drawn at. + public static func encode(_ image: CGImage, componentsX: Int, componentsY: Int) -> String? { + guard (1...9).contains(componentsX), (1...9).contains(componentsY) else { return nil } + let side = 32 + let bytesPerRow = side * 4 + var pixels = [UInt8](repeating: 0, count: side * bytesPerRow) + let drawn = pixels.withUnsafeMutableBytes { buffer -> Bool in + guard let context = CGContext( + data: buffer.baseAddress, + width: side, + height: side, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) else { return false } + context.interpolationQuality = .medium + context.draw(image, in: CGRect(x: 0, y: 0, width: side, height: side)) + return true + } + guard drawn else { return nil } + return encode(rgb: pixels, width: side, height: side, bytesPerRow: bytesPerRow, componentsX: componentsX, componentsY: componentsY) + } + + /// Encodes 8-bit sRGB pixels laid out as RGBx rows, the reference implementation's algorithm. + static func encode(rgb pixels: [UInt8], width: Int, height: Int, bytesPerRow: Int, componentsX: Int, componentsY: Int) -> String { + var linear = [SIMD3](repeating: .zero, count: width * height) + for y in 0..] = [] + for j in 0...zero + for y in 0.., _ maxValue: Float) -> Int { + func quantise(_ component: Float) -> Int { + let scaled = component / maxValue + let root = scaled < 0 ? -sqrtf(-scaled) : sqrtf(scaled) + return max(0, min(18, Int(floorf(root * 9 + 9.5)))) + } + return quantise(value.x) * 19 * 19 + quantise(value.y) * 19 + quantise(value.z) + } + + private static func encode83(_ value: Int, length: Int) -> String { + var result = "" + for i in 1...length { + var divisor = 1 + for _ in 0..<(length - i) { divisor *= 83 } + result.append(alphabet[(value / divisor) % 83]) + } + return result + } + + private static func srgbToLinear(_ colorEnc: Int) -> Float { + let v = Float(colorEnc) / 255 + return v <= 0.04045 ? v / 12.92 : powf((v + 0.055) / 1.055, 2.4) + } + + private static func linearToSrgb(_ value: Float) -> Int { + let v = min(max(value, 0), 1) + let srgb = v <= 0.0031308 ? v * 12.92 : 1.055 * powf(v, 1 / 2.4) - 0.055 + return Int(srgb * 255 + 0.5) + } + + private static let alphabet = Array( + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" + ) +} diff --git a/Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift b/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaUploader.swift similarity index 80% rename from Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift rename to FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaUploader.swift index 704b9ce21..8f699369f 100644 --- a/Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift +++ b/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaUploader.swift @@ -1,21 +1,21 @@ // // ChatMediaUploader.swift -// Flipcash +// FlipcashCore // // Copyright © 2026 Code Inc. All rights reserved. // -import UIKit +import CoreGraphics +import Foundation import ImageIO -import FlipcashCore -import FlipcashUI private let logger = Logger(label: "flipcash.chat-media-upload") /// The blob calls a chat photo upload makes, in the order it makes them. /// /// The owner is bound by the conformer, so the upload never handles keys. -protocol ChatMediaBlobStoring { +@MainActor +public protocol ChatMediaBlobStoring { /// Returns the upload constraints in force for the owner. func uploadPolicy() async throws -> UploadPolicy @@ -33,38 +33,15 @@ protocol ChatMediaBlobStoring { func awaitBlobFinalization(blobID: BlobID) async throws } -/// Stores chat photos on behalf of the signed-in owner. -struct SessionChatMediaBlobStore: ChatMediaBlobStoring { - - let session: Session - let flipClient: FlipClient - - func uploadPolicy() async throws -> UploadPolicy { - try await flipClient.uploadPolicy(owner: session.ownerKeyPair) - } - - func storeBlob(_ data: Data, mimeType: String, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) async throws -> BlobID { - try await flipClient.storeBlob(data, mimeType: mimeType, owner: session.ownerKeyPair, onProgress: onProgress) - } - - func storeEncryptedBlob(_ image: Data, seal: ChatSeal, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) async throws -> EncryptedBlobUpload { - try await flipClient.storeEncryptedBlob(image, seal: seal, owner: session.ownerKeyPair, onProgress: onProgress) - } - - func awaitBlobFinalization(blobID: BlobID) async throws { - try await flipClient.awaitBlobFinalization(blobID: blobID, owner: session.ownerKeyPair) - } -} - /// A chat photo uploaded and finalized, in the form its message references it. -enum UploadedPhoto: Hashable, Sendable, Codable { +public enum UploadedPhoto: Hashable, Sendable, Codable { /// A plaintext blob, whose metadata the server derives. case plain(BlobID) /// A blob end-to-end encrypted for the chat, with the metadata its sealed message carries. case sealed(SealedPhoto) /// The finalized blob. - var blobID: BlobID { + public var blobID: BlobID { switch self { case .plain(let blobID): blobID case .sealed(let photo): photo.blobID @@ -73,7 +50,7 @@ enum UploadedPhoto: Hashable, Sendable, Codable { } /// Why a chat photo did not upload. -enum ChatMediaUploadError: Error { +public enum ChatMediaUploadError: Error { /// The upload policy accepts no JPEG. case noMatchingConstraint /// The upload policy allows the owner no end-to-end encrypted upload. @@ -89,7 +66,7 @@ enum ChatMediaUploadError: Error { /// Whether the send can never succeed, so nothing about it is worth keeping: the server refused /// the bytes, or the photo can't be uploaded again. - var isTerminal: Bool { + public var isTerminal: Bool { switch self { case .rejected: true @@ -99,7 +76,7 @@ enum ChatMediaUploadError: Error { } /// Whether uploading the same photo again could succeed. - var isRetryable: Bool { + public var isRetryable: Bool { switch self { case .noMatchingConstraint, .encryptionNotAllowed, .encodingFailed: false @@ -121,7 +98,7 @@ enum ChatMediaUploadError: Error { } extension ChatMediaUploadError: ServerError { - var reportingLevel: ErrorReportingLevel { + public var reportingLevel: ErrorReportingLevel { switch self { case .noMatchingConstraint, .encryptionNotAllowed, .encodingFailed: .error @@ -138,18 +115,25 @@ extension ChatMediaUploadError: ServerError { /// Turns a staged photo into a finalized blob: downscaled to the upload policy's bounds, encoded /// down the JPEG quality ladder, stored, and awaited until the server has finalized it. In a chat /// that encrypts, the bytes are encrypted for it before they leave the device. -struct ChatMediaUploader { +@MainActor +public struct ChatMediaUploader { /// The seal to encrypt a photo with, or nil when the chat takes plaintext photos. - typealias SealProvider = @MainActor () async throws -> ChatSeal? + public typealias SealProvider = @MainActor () async throws -> ChatSeal? - let blob: any ChatMediaBlobStoring + public let blob: any ChatMediaBlobStoring /// Decides at upload time whether the photo is encrypted, the same decision text sends make. - var seal: SealProvider = { nil } + public var seal: SealProvider = { nil } /// The wait before each automatic retry of a store that failed in transit; one entry per retry. - var backoff: [Duration] = [.seconds(1), .seconds(2), .seconds(4)] + public var backoff: [Duration] = [.seconds(1), .seconds(2), .seconds(4)] + + public init(blob: any ChatMediaBlobStoring, seal: @escaping SealProvider = { nil }, backoff: [Duration] = [.seconds(1), .seconds(2), .seconds(4)]) { + self.blob = blob + self.seal = seal + self.backoff = backoff + } /// Returns the finalized photo for `image`, throwing `ChatMediaUploadError`. /// @@ -159,19 +143,19 @@ struct ChatMediaUploader { /// finalized it, so a failed wait can resume through ``finalize(_:progress:)`` instead of /// storing again. `progress`, when given, follows each store attempt's bytes and then the /// server's processing. - func upload( - _ image: UIImage, - progress: ChatPhotoSendProgress? = nil, + public func upload( + _ image: CGImage, + orientation: CGImagePropertyOrientation = .up, + progress: (any ChatPhotoUploadProgress)? = nil, onStored: (UploadedPhoto) -> Void = { _ in }, onPrepared: (Int, Int) -> Void, onEncoded: (Data) -> Void = { _ in } ) async throws -> UploadedPhoto { let (chatSeal, bounds, maxSizeBytes) = try await resolveConstraints() - guard let cgImage = image.cgImage, maxSizeBytes > 0 else { + guard maxSizeBytes > 0 else { throw ChatMediaUploadError.encodingFailed(.encodingFailed) } - let orientation = CGImagePropertyOrientation(image.imageOrientation) let isSideways: Bool switch orientation { case .left, .leftMirrored, .right, .rightMirrored: @@ -181,8 +165,8 @@ struct ChatMediaUploader { } let target = ChatMediaDownscale.target( - sourceWidth: isSideways ? cgImage.height : cgImage.width, - sourceHeight: isSideways ? cgImage.width : cgImage.height, + sourceWidth: isSideways ? image.height : image.width, + sourceHeight: isSideways ? image.width : image.height, maxWidth: bounds?.maxWidth ?? 0, maxHeight: bounds?.maxHeight ?? 0, maxPixels: bounds?.maxPixels ?? 0 @@ -191,7 +175,7 @@ struct ChatMediaUploader { let data: Data do { - data = try await Self.encode(cgImage, orientation: orientation, target: target, maxSizeBytes: maxSizeBytes) + data = try await Self.encode(image, orientation: orientation, target: target, maxSizeBytes: maxSizeBytes) } catch let error as ChatMediaEncoder.Error { throw ChatMediaUploadError.encodingFailed(error) } @@ -202,9 +186,9 @@ struct ChatMediaUploader { /// Returns the finalized photo for `jpeg`, bytes an earlier ``upload(_:progress:onStored:onPrepared:onEncoded:)`` /// produced, stored as they are rather than encoded again; throws `ChatMediaUploadError`. - func upload( + public func upload( jpeg: Data, - progress: ChatPhotoSendProgress? = nil, + progress: (any ChatPhotoUploadProgress)? = nil, onStored: (UploadedPhoto) -> Void = { _ in }, onPrepared: (Int, Int) -> Void ) async throws -> UploadedPhoto { @@ -259,7 +243,7 @@ struct ChatMediaUploader { width: Int, height: Int, chatSeal: ChatSeal?, - progress: ChatPhotoSendProgress?, + progress: (any ChatPhotoUploadProgress)?, onStored: (UploadedPhoto) -> Void ) async throws -> UploadedPhoto { // Byte counts arrive on URLSession's delegate queue. @@ -289,7 +273,7 @@ struct ChatMediaUploader { /// Returns `photo` once the server has finalized its stored bytes, throwing /// `ChatMediaUploadError`. - func finalize(_ photo: UploadedPhoto, progress: ChatPhotoSendProgress? = nil) async throws -> UploadedPhoto { + public func finalize(_ photo: UploadedPhoto, progress: (any ChatPhotoUploadProgress)? = nil) async throws -> UploadedPhoto { progress?.beginProcessing() do { @@ -305,7 +289,7 @@ struct ChatMediaUploader { } /// Stores `data`, retrying through `backoff` while the failure is in transit. - private func store(progress: ChatPhotoSendProgress?, _ attemptStore: () async throws -> Stored) async throws -> Stored { + private func store(progress: (any ChatPhotoUploadProgress)?, _ attemptStore: () async throws -> Stored) async throws -> Stored { var attempt = 0 while true { // A retried store sends every byte again. @@ -355,7 +339,7 @@ struct ChatMediaUploader { /// decrypts, since the server cannot derive one. Empty when the bytes don't decode, which the /// contract allows. @concurrent - nonisolated static func blurhash(of jpeg: Data) async -> String { + public nonisolated static func blurhash(of jpeg: Data) async -> String { guard let source = CGImageSourceCreateWithData(jpeg as CFData, nil), let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, [ kCGImageSourceCreateThumbnailFromImageAlways: true, @@ -363,22 +347,21 @@ struct ChatMediaUploader { kCGImageSourceThumbnailMaxPixelSize: 64, ] as CFDictionary) else { return "" } let landscape = thumbnail.width >= thumbnail.height - return BlurHash.encode(thumbnail, componentsX: landscape ? 4 : 3, componentsY: landscape ? 3 : 4) ?? "" + return BlurHashEncoder.encode(thumbnail, componentsX: landscape ? 4 : 3, componentsY: landscape ? 3 : 4) ?? "" } } -private extension CGImagePropertyOrientation { - init(_ orientation: UIImage.Orientation) { - switch orientation { - case .up: self = .up - case .upMirrored: self = .upMirrored - case .down: self = .down - case .downMirrored: self = .downMirrored - case .left: self = .left - case .leftMirrored: self = .leftMirrored - case .right: self = .right - case .rightMirrored: self = .rightMirrored - @unknown default: self = .up - } - } +/// What a photo upload reports about its progress, so the app can drive a bubble's overlay while an +/// extension drives a plain progress bar. +@MainActor +public protocol ChatPhotoUploadProgress: AnyObject, Sendable { + + /// Starts an upload attempt over, including a retried store. + func beginAttempt() + + /// Records bytes sent to storage. + func didUpload(_ progress: BlobUploadProgress) + + /// Records that the bytes are stored and the server is finalizing them. + func beginProcessing() } diff --git a/FlipcashCore/Sources/FlipcashCore/Blob/ChatPhotoDecoder.swift b/FlipcashCore/Sources/FlipcashCore/Blob/ChatPhotoDecoder.swift new file mode 100644 index 000000000..5506db474 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Blob/ChatPhotoDecoder.swift @@ -0,0 +1,45 @@ +// +// ChatPhotoDecoder.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import CoreGraphics +import Foundation +import ImageIO + +/// Decodes photo bytes into upright pixels through ImageIO. +public enum ChatPhotoDecoder { + + /// Returns the photo in `data` with its EXIF orientation drawn into the pixels, or `nil` when it + /// cannot be decoded. Full size unless `maxPixelSize` caps the longer edge. Off the caller's + /// actor, since a full-size photo takes a while to parse. + @concurrent + public static func decode(_ data: Data, maxPixelSize: Int? = nil) async -> CGImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } + return upright(source, maxPixelSize: maxPixelSize) + } + + /// Returns the photo at `url` as ``decode(_:maxPixelSize:)`` does, reading the file through + /// ImageIO rather than into memory first. + @concurrent + public static func decode(contentsOf url: URL, maxPixelSize: Int? = nil) async -> CGImage? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, [kCGImageSourceShouldCache: false] as CFDictionary) else { return nil } + return upright(source, maxPixelSize: maxPixelSize) + } + + private static func upright(_ source: CGImageSource, maxPixelSize: Int?) -> CGImage? { + guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int else { + return nil + } + let longestEdge = max(width, height) + return CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize.map { min($0, longestEdge) } ?? longestEdge, + ] as CFDictionary) + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Blob/ChatPhotoSend.swift b/FlipcashCore/Sources/FlipcashCore/Blob/ChatPhotoSend.swift new file mode 100644 index 000000000..268c95b21 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Blob/ChatPhotoSend.swift @@ -0,0 +1,40 @@ +// +// ChatPhotoSend.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// How an uploaded photo goes out given the chat's current seal. +public enum ChatPhotoSend: 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 + + public static func == (lhs: ChatPhotoSend, rhs: ChatPhotoSend) -> 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. + public static func matching(_ photo: UploadedPhoto, seal: ChatSeal?, conversationID: ConversationID) -> ChatPhotoSend { + 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 + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Blob/FlipClientChatMediaBlobStore.swift b/FlipcashCore/Sources/FlipcashCore/Blob/FlipClientChatMediaBlobStore.swift new file mode 100644 index 000000000..46e59345e --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Blob/FlipClientChatMediaBlobStore.swift @@ -0,0 +1,37 @@ +// +// FlipClientChatMediaBlobStore.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// Stores chat photos for `owner` straight through a `FlipClient`, for a process with no session, +/// such as a share extension. +public struct FlipClientChatMediaBlobStore: ChatMediaBlobStoring { + + private let flipClient: FlipClient + private let owner: KeyPair + + public init(flipClient: FlipClient, owner: KeyPair) { + self.flipClient = flipClient + self.owner = owner + } + + public func uploadPolicy() async throws -> UploadPolicy { + try await flipClient.uploadPolicy(owner: owner) + } + + public func storeBlob(_ data: Data, mimeType: String, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) async throws -> BlobID { + try await flipClient.storeBlob(data, mimeType: mimeType, owner: owner, onProgress: onProgress) + } + + public func storeEncryptedBlob(_ image: Data, seal: ChatSeal, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) async throws -> EncryptedBlobUpload { + try await flipClient.storeEncryptedBlob(image, seal: seal, owner: owner, onProgress: onProgress) + } + + public func awaitBlobFinalization(blobID: BlobID) async throws { + try await flipClient.awaitBlobFinalization(blobID: blobID, owner: owner) + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation+DisplayName.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation+DisplayName.swift new file mode 100644 index 000000000..b41f17c4e --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation+DisplayName.swift @@ -0,0 +1,38 @@ +// +// Conversation+DisplayName.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +extension Conversation { + + /// Counterpart name shown when neither the synced contacts, the feed, nor a shared phone number + /// provides one. + public static let fallbackCounterpartName = "Flipcash User" + + /// The conversation's name: its group title, else the synced contact's address-book name, else + /// the server-provided member name from the feed, else the counterpart's shared phone number, + /// else a generic fallback. + /// + /// `contactName` is asked for the chat's id and may return nil or an empty string. Tip DMs skip + /// it — their derived ids can never match a contact's, so the directory scan is a guaranteed miss. + public func displayName(selfUserID: UserID?, contactName: (ConversationID) -> String?) -> String { + // Group chats are named by their server-set title, not a counterpart. + if let title, !title.isEmpty { + return title + } + if type != .tipDm, let name = contactName(id), !name.isEmpty { + return name + } + guard let counterpart = counterpart(excluding: selfUserID) else { + return Self.fallbackCounterpartName + } + if !counterpart.displayName.isEmpty { + return counterpart.displayName + } + return counterpart.formattedPhoneNumber ?? Self.fallbackCounterpartName + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatPhotoDecoderTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatPhotoDecoderTests.swift new file mode 100644 index 000000000..1c67ec953 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatPhotoDecoderTests.swift @@ -0,0 +1,57 @@ +// +// ChatPhotoDecoderTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import CoreGraphics +import Foundation +import ImageIO +import Testing +import UniformTypeIdentifiers +@testable import FlipcashCore + +@Suite("Chat photo decoder") +struct ChatPhotoDecoderTests { + + private static func jpeg(width: Int, height: Int, orientation: CGImagePropertyOrientation = .up) throws -> Data { + let context = try #require(CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + )) + context.setFillColor(CGColor(red: 0, green: 0, blue: 1, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + let data = NSMutableData() + let destination = try #require(CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil)) + CGImageDestinationAddImage(destination, try #require(context.makeImage()), [kCGImagePropertyOrientation: orientation.rawValue] as CFDictionary) + #expect(CGImageDestinationFinalize(destination)) + return data as Data + } + + @Test("A sideways EXIF orientation is drawn into the pixels") + func bakesOrientation() async throws { + let decoded = try #require(await ChatPhotoDecoder.decode(Self.jpeg(width: 4, height: 3, orientation: .right))) + + #expect(decoded.width == 3) + #expect(decoded.height == 4) + } + + @Test("A cap on the longer edge downsamples, and never upsamples") + func capsLongerEdge() async throws { + let data = try Self.jpeg(width: 400, height: 200) + + let capped = try #require(await ChatPhotoDecoder.decode(data, maxPixelSize: 100)) + let uncapped = try #require(await ChatPhotoDecoder.decode(data, maxPixelSize: 4000)) + + #expect(capped.width == 100) + #expect(capped.height == 50) + #expect(uncapped.width == 400) + } + + @Test("Bytes that are not an image decode to nothing") + func rejectsGarbage() async { + #expect(await ChatPhotoDecoder.decode(Data([1, 2, 3])) == nil) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatPhotoSendTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatPhotoSendTests.swift new file mode 100644 index 000000000..8210ee13f --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatPhotoSendTests.swift @@ -0,0 +1,51 @@ +// +// ChatPhotoSendTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import SharedCore +import Testing +@testable import FlipcashCore + +@Suite("Chat photo send") +struct ChatPhotoSendTests { + + private static func seal() throws -> ChatSeal { + try ChatSeal( + cipher: DefaultChatCipher.shared, + owner: KeyPair.generate()!, + peerPublicKey: KeyPair.generate()!.publicKey, + conversationID: ConversationID(data: Data(repeating: 0x07, count: 32)), + selfUserID: UUID() + ) + } + + @Test("A refused photo reports as an expected outcome, a client defect as an error") + func reportingLevels() { + #expect(ChatMediaUploadError.rejected(.moderation).reportingLevel == .info) + #expect(ChatMediaUploadError.noMatchingConstraint.reportingLevel == .error) + #expect(ChatMediaUploadError.failed(ErrorBlob.quotaExceeded).reportingLevel == .info) + #expect(ChatMediaUploadError.failed(ErrorBlob.unknown).reportingLevel == .error) + } + + @Test("A photo goes out only under the encryption choice it was uploaded for") + func photoSendMatchesSeal() throws { + let seal = try Self.seal() + let chat = seal.conversationID + let other = ConversationID(data: Data(repeating: 0x08, count: 32)) + let blobID = BlobID(data: Data([1])) + let sealed = SealedPhoto(blobID: blobID, mimeType: "image/jpeg", sizeBytes: 10, width: 1, height: 1, blurhash: "00") + + #expect(ChatPhotoSend.matching(.plain(blobID), seal: nil, conversationID: chat) == .plain(blobID)) + #expect(ChatPhotoSend.matching(.sealed(sealed), seal: seal, conversationID: chat) == .sealed(sealed, seal)) + // The chat started encrypting after the photo uploaded in plaintext. + #expect(ChatPhotoSend.matching(.plain(blobID), seal: seal, conversationID: chat) == .mismatch) + // The chat stopped encrypting after the photo uploaded sealed. + #expect(ChatPhotoSend.matching(.sealed(sealed), seal: nil, conversationID: chat) == .mismatch) + // A seal for a different chat. + #expect(ChatPhotoSend.matching(.sealed(sealed), seal: seal, conversationID: other) == .mismatch) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationDisplayNameTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationDisplayNameTests.swift new file mode 100644 index 000000000..cfb1f5998 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationDisplayNameTests.swift @@ -0,0 +1,62 @@ +// +// ConversationDisplayNameTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +@testable import FlipcashCore + +@Suite("Conversation display name") +struct ConversationDisplayNameTests { + + private let me = UUID() + private let peer = UUID() + + private func conversation(type: ConversationType = .contactDm, title: String? = nil, peerName: String = "Peer") -> Conversation { + var conversation = Conversation( + id: ConversationID(data: Data(repeating: 1, count: 32)), + members: [ + ConversationMember(userID: me, displayName: "Me"), + ConversationMember(userID: peer, displayName: peerName), + ], + lastMessage: nil, + lastActivity: .now, + type: type, + isHidden: false + ) + conversation.title = title + return conversation + } + + @Test("A group's title wins over the contact lookup") + func titleWins() { + let name = conversation(type: .group, title: "Crew").displayName(selfUserID: me) { _ in "Contact" } + #expect(name == "Crew") + } + + @Test("The injected contact name wins over the feed's member name") + func contactNameWins() { + #expect(conversation().displayName(selfUserID: me) { _ in "Contact" } == "Contact") + } + + @Test("An empty contact name falls through to the member name") + func emptyContactFallsThrough() { + #expect(conversation().displayName(selfUserID: me) { _ in "" } == "Peer") + } + + @Test("A tip DM never asks for a contact name") + func tipDmSkipsLookup() { + var asked = false + let name = conversation(type: .tipDm).displayName(selfUserID: me) { _ in asked = true; return "Contact" } + #expect(!asked) + #expect(name == "Peer") + } + + @Test("A counterpart with no name falls back to the generic name") + func genericFallback() { + #expect(conversation(peerName: "").displayName(selfUserID: me) { _ in nil } == Conversation.fallbackCounterpartName) + } +} diff --git a/FlipcashTests/Chat/ChatMediaUploaderTests.swift b/FlipcashTests/Chat/ChatMediaUploaderTests.swift index aa5f1813a..f87610fe5 100644 --- a/FlipcashTests/Chat/ChatMediaUploaderTests.swift +++ b/FlipcashTests/Chat/ChatMediaUploaderTests.swift @@ -384,30 +384,4 @@ struct ChatMediaUploaderTests { return error as? ChatMediaUploadError } } - - @Test("A refused photo reports as an expected outcome, a client defect as an error") - func reportingLevels() { - #expect(ChatMediaUploadError.rejected(.moderation).reportingLevel == .info) - #expect(ChatMediaUploadError.noMatchingConstraint.reportingLevel == .error) - #expect(ChatMediaUploadError.failed(ErrorBlob.quotaExceeded).reportingLevel == .info) - #expect(ChatMediaUploadError.failed(ErrorBlob.unknown).reportingLevel == .error) - } - - @Test("A photo goes out only under the encryption choice it was uploaded for") - func photoSendMatchesSeal() throws { - let seal = try Self.seal() - let chat = seal.conversationID - let other = ConversationID(data: Data(repeating: 0x08, count: 32)) - let blobID = BlobID(data: Data([1])) - let sealed = SealedPhoto(blobID: blobID, mimeType: "image/jpeg", sizeBytes: 10, width: 1, height: 1, blurhash: "00") - - #expect(EncryptedChatClient.photoSend(.plain(blobID), seal: nil, conversationID: chat) == .plain(blobID)) - #expect(EncryptedChatClient.photoSend(.sealed(sealed), seal: seal, conversationID: chat) == .sealed(sealed, seal)) - // The chat started encrypting after the photo uploaded in plaintext. - #expect(EncryptedChatClient.photoSend(.plain(blobID), seal: seal, conversationID: chat) == .mismatch) - // The chat stopped encrypting after the photo uploaded sealed. - #expect(EncryptedChatClient.photoSend(.sealed(sealed), seal: nil, conversationID: chat) == .mismatch) - // A seal for a different chat. - #expect(EncryptedChatClient.photoSend(.sealed(sealed), seal: seal, conversationID: other) == .mismatch) - } } diff --git a/FlipcashTests/Chat/ComposerChipLayoutTests.swift b/FlipcashTests/Chat/ComposerChipLayoutTests.swift index 74b7be06b..f07f29e75 100644 --- a/FlipcashTests/Chat/ComposerChipLayoutTests.swift +++ b/FlipcashTests/Chat/ComposerChipLayoutTests.swift @@ -7,6 +7,7 @@ import Testing import UIKit +import FlipcashCore @testable import Flipcash @MainActor diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatPhotoSendProgress.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPhotoSendProgress.swift index a8ff05d43..4404bd6a1 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatPhotoSendProgress.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPhotoSendProgress.swift @@ -86,3 +86,5 @@ public final class ChatPhotoSendProgress { phase = .failed } } + +extension ChatPhotoSendProgress: ChatPhotoUploadProgress {} diff --git a/FlipcashUI/Sources/FlipcashUI/Images/BlurHash.swift b/FlipcashUI/Sources/FlipcashUI/Images/BlurHash.swift index d8982d591..f1ab54c72 100644 --- a/FlipcashUI/Sources/FlipcashUI/Images/BlurHash.swift +++ b/FlipcashUI/Sources/FlipcashUI/Images/BlurHash.swift @@ -5,6 +5,7 @@ import SwiftUI import UIKit +import FlipcashCore /// Decoder for [BlurHash](https://blurha.sh) strings — the compact, blurred image preview that /// ships in a media item's `ImageMetadata`. Decode it into a tiny image and let the image layer @@ -186,101 +187,8 @@ nonisolated public enum BlurHash { /// Encodes `image` into a hash with `componentsX` × `componentsY` components (each 1…9), or nil /// when the image cannot be drawn. - /// - /// The image is sampled at a small fixed size first: a hash keeps only the lowest frequencies, so - /// more pixels change nothing but the cost. Squashing it to a square is harmless for the same - /// reason; the decoder stretches back to whatever aspect it is drawn at. public static func encode(_ image: CGImage, componentsX: Int, componentsY: Int) -> String? { - guard (1...9).contains(componentsX), (1...9).contains(componentsY) else { return nil } - let side = 32 - let bytesPerRow = side * 4 - var pixels = [UInt8](repeating: 0, count: side * bytesPerRow) - let drawn = pixels.withUnsafeMutableBytes { buffer -> Bool in - guard let context = CGContext( - data: buffer.baseAddress, - width: side, - height: side, - bitsPerComponent: 8, - bytesPerRow: bytesPerRow, - space: CGColorSpace(name: CGColorSpace.sRGB)!, - bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue - ) else { return false } - context.interpolationQuality = .medium - context.draw(image, in: CGRect(x: 0, y: 0, width: side, height: side)) - return true - } - guard drawn else { return nil } - return encode(rgb: pixels, width: side, height: side, bytesPerRow: bytesPerRow, componentsX: componentsX, componentsY: componentsY) - } - - /// Encodes 8-bit sRGB pixels laid out as RGBx rows, the reference implementation's algorithm. - static func encode(rgb pixels: [UInt8], width: Int, height: Int, bytesPerRow: Int, componentsX: Int, componentsY: Int) -> String { - var linear = [SIMD3](repeating: .zero, count: width * height) - for y in 0..] = [] - for j in 0...zero - for y in 0.., _ maxValue: Float) -> Int { - func quantise(_ component: Float) -> Int { - let scaled = component / maxValue - let root = scaled < 0 ? -sqrtf(-scaled) : sqrtf(scaled) - return max(0, min(18, Int(floorf(root * 9 + 9.5)))) - } - return quantise(value.x) * 19 * 19 + quantise(value.y) * 19 + quantise(value.z) - } - - private static func encode83(_ value: Int, length: Int) -> String { - var result = "" - for i in 1...length { - var divisor = 1 - for _ in 0..<(length - i) { divisor *= 83 } - result.append(alphabet[(value / divisor) % 83]) - } - return result + BlurHashEncoder.encode(image, componentsX: componentsX, componentsY: componentsY) } private static let alphabet = Array(