From abdd63e8739ee708a28491500e95811bba91d14f Mon Sep 17 00:00:00 2001 From: Taksh Date: Tue, 25 Aug 2026 08:37:30 +0530 Subject: [PATCH 1/3] fix: encode photos up to the 512 KiB image budget The protocol already allows 512 KiB images and Android encodes at 512px without a second squeeze. Crushing iOS JPEGs to 45 KB at 448px made photos unreadable on the same radio as voice notes. --- bitchat/Features/media/ImageUtils.swift | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/bitchat/Features/media/ImageUtils.swift b/bitchat/Features/media/ImageUtils.swift index a6eb25d1cf..8c83007a78 100644 --- a/bitchat/Features/media/ImageUtils.swift +++ b/bitchat/Features/media/ImageUtils.swift @@ -1,3 +1,4 @@ +import BitFoundation import Foundation import ImageIO import UniformTypeIdentifiers @@ -14,10 +15,14 @@ enum ImageUtilsError: Error { enum ImageUtils { private static let compressionQuality: CGFloat = 0.82 - private static let targetImageBytes: Int = 45_000 + /// Encode against the protocol image budget, not a second magic squeeze. + /// Voice notes already travel at 512 KiB; photos should too. Android + /// encodes at 512px / q=85 with no 45 KB crush, so matching that budget + /// is what keeps a photo readable on the same radio. + private static let targetImageBytes: Int = FileTransferLimits.maxImageBytes private static let maxSourceImageBytes: Int = 10 * 1024 * 1024 - static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL { + static func processImage(at url: URL, maxDimension: CGFloat = 512, outputDirectory: URL? = nil) throws -> URL { try validateImageSource(at: url) let data = try Data(contentsOf: url) @@ -47,7 +52,7 @@ enum ImageUtils { } #if os(iOS) - static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL { + static func processImage(_ image: UIImage, maxDimension: CGFloat = 512, outputDirectory: URL? = nil) throws -> URL { return try autoreleasepool { // Scale the image first let scaled = scaledImage(image, maxDimension: maxDimension) @@ -82,11 +87,12 @@ enum ImageUtils { private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage { let size = image.size let maxSide = max(size.width, size.height) - guard maxSide > maxDimension else { return image } - let scale = maxDimension / maxSide + let scale = maxSide > maxDimension ? maxDimension / maxSide : 1 let newSize = CGSize(width: size.width * scale, height: size.height * scale) - // Draw into a new context to get a clean CGImage without metadata + // Always redraw so EXIF orientation is baked in even when the + // pixel size already fits. Returning the original left + // `image.cgImage` rotated for camera stills under 512px. UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0) image.draw(in: CGRect(origin: .zero, size: newSize)) let rendered = UIGraphicsGetImageFromCurrentImageContext() @@ -115,7 +121,7 @@ enum ImageUtils { return data as Data } #else - static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL { + static func processImage(_ image: NSImage, maxDimension: CGFloat = 512, outputDirectory: URL? = nil) throws -> URL { return try autoreleasepool { let scaled = scaledImage(image, maxDimension: maxDimension) guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else { @@ -160,8 +166,7 @@ enum ImageUtils { private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage { let size = image.size let maxSide = max(size.width, size.height) - guard maxSide > maxDimension else { return image } - let scale = maxDimension / maxSide + let scale = maxSide > maxDimension ? maxDimension / maxSide : 1 let newSize = NSSize(width: size.width * scale, height: size.height * scale) let scaledImage = NSImage(size: newSize) scaledImage.lockFocus() From a5e7a3efc84cc9dc6c93457991f98771b000a260 Mon Sep 17 00:00:00 2001 From: Taksh Date: Tue, 25 Aug 2026 08:37:30 +0530 Subject: [PATCH 2/3] test: assert photo encoding uses the 512 KiB budget A noisy 1600x1200 source must land above the old 45 KB crush, at or under FileTransferLimits.maxImageBytes, with a 512px long edge. --- bitchatTests/Features/ImageUtilsTests.swift | 67 +++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/bitchatTests/Features/ImageUtilsTests.swift b/bitchatTests/Features/ImageUtilsTests.swift index a130b4eaca..ad18c1627d 100644 --- a/bitchatTests/Features/ImageUtilsTests.swift +++ b/bitchatTests/Features/ImageUtilsTests.swift @@ -1,5 +1,7 @@ import Testing import Foundation +import ImageIO +import BitFoundation #if os(iOS) import UIKit #else @@ -22,6 +24,24 @@ private func makePlatformImage(size: CGSize) -> UIImage { context.fill(CGRect(origin: .zero, size: size)) } } + +private func makeNoisyPlatformImage(size: CGSize) -> UIImage { + UIGraphicsImageRenderer(size: size).image { context in + for i in 0..<600 { + UIColor( + hue: CGFloat(i % 47) / 47, + saturation: 1, + brightness: CGFloat((i * 13) % 100) / 100, + alpha: 1 + ).setStroke() + let path = UIBezierPath() + path.move(to: CGPoint(x: CGFloat(i * 3 % Int(size.width)), y: 0)) + path.addLine(to: CGPoint(x: 0, y: CGFloat(i * 5 % Int(size.height)))) + path.lineWidth = 2 + path.stroke() + } + } +} #else private func makePlatformImage(size: CGSize) -> NSImage { let image = NSImage(size: size) @@ -31,8 +51,38 @@ private func makePlatformImage(size: CGSize) -> NSImage { image.unlockFocus() return image } + +private func makeNoisyPlatformImage(size: CGSize) -> NSImage { + let image = NSImage(size: size) + image.lockFocus() + for i in 0..<600 { + NSColor( + hue: CGFloat(i % 47) / 47, + saturation: 1, + brightness: CGFloat((i * 13) % 100) / 100, + alpha: 1 + ).setStroke() + let path = NSBezierPath() + path.move(to: CGPoint(x: CGFloat(i * 3 % Int(size.width)), y: 0)) + path.line(to: CGPoint(x: 0, y: CGFloat(i * 5 % Int(size.height)))) + path.lineWidth = 2 + path.stroke() + } + image.unlockFocus() + return image +} #endif +private func jpegPixelSize(_ data: Data) -> CGSize { + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = props[kCGImagePropertyPixelWidth] as? Int, + let height = props[kCGImagePropertyPixelHeight] as? Int else { + return .zero + } + return CGSize(width: width, height: height) +} + struct ImageUtilsTests { @Test func processImage_rejectsOversizedSourceFile() throws { @@ -72,6 +122,23 @@ struct ImageUtilsTests { #expect(data.count > 0) } + @Test + func processImage_usesThe512KiBBudgetInsteadOfCrushingTo45KB() throws { + let image = makeNoisyPlatformImage(size: CGSize(width: 1600, height: 1200)) + let outputDirectory = makeTemporaryDirectoryURL("image-budget-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: outputDirectory) } + + let outputURL = try ImageUtils.processImage(image, outputDirectory: outputDirectory) + let data = try Data(contentsOf: outputURL) + let pixelSize = jpegPixelSize(data) + + #expect(data.count > 45_000) + #expect(data.count <= FileTransferLimits.maxImageBytes) + #expect(pixelSize.width <= 512) + #expect(pixelSize.height <= 512) + #expect(max(pixelSize.width, pixelSize.height) == 512) + } + @Test func processImage_usesUniqueOutputURLs() throws { let image = makePlatformImage(size: CGSize(width: 64, height: 64)) From fb0e71d2917615d9a2318f372b92dd105311d332 Mon Sep 17 00:00:00 2001 From: Taksh Date: Tue, 25 Aug 2026 10:13:46 +0530 Subject: [PATCH 3/3] fix: drop the unused renderer context in the noisy-image test SwiftLint unused_closure_parameter fails the suite otherwise. --- bitchatTests/Features/ImageUtilsTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitchatTests/Features/ImageUtilsTests.swift b/bitchatTests/Features/ImageUtilsTests.swift index ad18c1627d..265095e491 100644 --- a/bitchatTests/Features/ImageUtilsTests.swift +++ b/bitchatTests/Features/ImageUtilsTests.swift @@ -26,7 +26,7 @@ private func makePlatformImage(size: CGSize) -> UIImage { } private func makeNoisyPlatformImage(size: CGSize) -> UIImage { - UIGraphicsImageRenderer(size: size).image { context in + UIGraphicsImageRenderer(size: size).image { _ in for i in 0..<600 { UIColor( hue: CGFloat(i % 47) / 47,