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
17 changes: 2 additions & 15 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1571,28 +1571,15 @@ 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,
/// else the counterpart's shared phone number, else a generic fallback.
/// 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
Expand Down
34 changes: 1 addition & 33 deletions Flipcash/Core/Controllers/EncryptedChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
}
Expand Down
51 changes: 51 additions & 0 deletions Flipcash/Core/Screens/Conversation/ChatMediaUploader+UIImage.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
12 changes: 1 addition & 11 deletions Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
32 changes: 32 additions & 0 deletions Flipcash/Core/Screens/Conversation/SessionChatMediaBlobStore.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
129 changes: 129 additions & 0 deletions FlipcashCore/Sources/FlipcashCore/Blob/BlurHashEncoder.swift
Original file line number Diff line number Diff line change
@@ -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<Float>](repeating: .zero, count: width * height)
for y in 0..<height {
for x in 0..<width {
let offset = y * bytesPerRow + x * 4
linear[y * width + x] = SIMD3(
srgbToLinear(Int(pixels[offset])),
srgbToLinear(Int(pixels[offset + 1])),
srgbToLinear(Int(pixels[offset + 2]))
)
}
}

var factors: [SIMD3<Float>] = []
for j in 0..<componentsY {
for i in 0..<componentsX {
let normalisation: Float = (i == 0 && j == 0) ? 1 : 2
var sum = SIMD3<Float>.zero
for y in 0..<height {
let basisY = cosf(.pi * Float(j) * Float(y) / Float(height))
for x in 0..<width {
let basis = normalisation * cosf(.pi * Float(i) * Float(x) / Float(width)) * basisY
sum += basis * linear[y * width + x]
}
}
factors.append(sum / Float(width * height))
}
}

let dc = factors[0]
let ac = factors.dropFirst()

var hash = encode83((componentsX - 1) + (componentsY - 1) * 9, length: 1)
let maxValue: Float
if let actualMax = ac.map({ max(abs($0.x), abs($0.y), abs($0.z)) }).max() {
let quantisedMax = max(0, min(82, Int(floorf(actualMax * 166 - 0.5))))
maxValue = Float(quantisedMax + 1) / 166
hash += encode83(quantisedMax, length: 1)
} else {
maxValue = 1
hash += encode83(0, length: 1)
}

hash += encode83((linearToSrgb(dc.x) << 16) + (linearToSrgb(dc.y) << 8) + linearToSrgb(dc.z), length: 4)
for factor in ac {
hash += encode83(encodeAc(factor, maxValue), length: 2)
}
return hash
}

private static func encodeAc(_ value: SIMD3<Float>, _ 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#$%*+,-.:;=?@[]^_{|}~"
)
}
Loading
Loading