Wi-Fi bulk: raise image-prep budget so real photos clear the 64 KiB AWDL threshold

Image prep downscaled every photo to 448 px and force-compressed to a
45 KB byte budget, so a typical camera photo landed ~40 KB — below
TransportConfig.wifiBulkMinPayloadBytes (64 KiB). WifiBulkPolicy.shouldOffer
requires payloadBytes > 64 KiB, so the Wi-Fi bulk (AWDL) data plane never
triggered in production even when it worked on-device: real photos always
fell back to BLE fragmentation.

Raise the prep budget so genuinely detailed photos land well above 64 KiB
while staying under the 512 KiB FileTransferLimits.maxImageBytes hard cap:
  - defaultMaxDimension 448 -> 1024 px
  - compressionQuality 0.82 -> 0.85
  - targetImageBytes 45 KB -> 200 KB (a ceiling, not a target to hit)

Measured on a representative photo-like image: ~40 KB before -> ~190 KB
after, crossing the 64 KiB offer threshold while remaining ~2.6x under the
hard cap.

Because the raised dimension makes near-incompressible inputs (e.g. full-
frame noise) able to exceed maxImageBytes at the quality floor, prep now
downscales-and-retries until the payload fits the hard cap, so a send can
never fail with imageTooLarge on pathological input (it couldn't at 448 px).
Also de-dupes the previously copy-pasted per-platform encodeJPEG into one
shared helper.

Residual limitation: prep is not recipient-capability-aware. Images are
prepared at this fidelity regardless of whether the recipient supports
Wi-Fi bulk, so BLE-only peers and public broadcasts now carry ~190 KB
images too (still within the existing 512 KiB image cap). Making prep
choose fidelity per-recipient would need capability plumbing into the prep
pipeline and doesn't help public broadcasts, so it's deferred.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-07 13:34:20 +02:00
co-authored by Claude Fable 5
parent 2b7fd2002b
commit 9b8256bf72
2 changed files with 172 additions and 82 deletions
+77 -53
View File
@@ -1,3 +1,4 @@
import BitFoundation
import Foundation import Foundation
import ImageIO import ImageIO
import UniformTypeIdentifiers import UniformTypeIdentifiers
@@ -13,11 +14,28 @@ enum ImageUtilsError: Error {
} }
enum ImageUtils { enum ImageUtils {
private static let compressionQuality: CGFloat = 0.82 private static let compressionQuality: CGFloat = 0.85
private static let targetImageBytes: Int = 45_000 // Upper bound for the compressed JPEG. This is only a ceiling: the encoder
// keeps whatever a photo naturally weighs at `defaultMaxDimension` and
// `compressionQuality`, and only steps quality down when a payload would
// exceed this budget. It stays well under `FileTransferLimits.maxImageBytes`
// (512 KiB) so the BLE path never overruns its cap.
//
// Wi-Fi bulk relevance: the old 45 KB / 448 px budget crushed every photo
// to ~40 KB below `TransportConfig.wifiBulkMinPayloadBytes` (64 KiB) so
// `WifiBulkPolicy.shouldOffer` never fired and the AWDL data plane was dead
// in production. A genuinely detailed photo at `defaultMaxDimension` now
// weighs well over 64 KiB, so it becomes Wi-Fi-bulk eligible to a capable
// direct peer while still riding BLE fragmentation for everyone else.
private static let targetImageBytes: Int = 200_000
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024 private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
// Longest-side ceiling for shared photos. 448 px was thumbnail-tier and
// (together with the tiny byte budget) forced every photo below the Wi-Fi
// bulk threshold. 1024 px keeps a shared photo legible and lets detailed
// images clear 64 KiB, without approaching the 512 KiB hard cap.
static let defaultMaxDimension: CGFloat = 1024
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL { static func processImage(at url: URL, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
try validateImageSource(at: url) try validateImageSource(at: url)
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
@@ -47,34 +65,33 @@ enum ImageUtils {
} }
#if os(iOS) #if os(iOS)
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL { static func processImage(_ image: UIImage, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
return try autoreleasepool { return try autoreleasepool {
// Scale the image first var dimension = maxDimension
let scaled = scaledImage(image, maxDimension: maxDimension) var jpegData: Data?
// Downscale-and-compress until the payload fits the hard image cap.
// A normal photo converges on the first pass; this loop only kicks
// in for near-incompressible inputs (e.g. full-frame noise) that
// would otherwise overrun `maxImageBytes` at the raised dimension.
while true {
let scaled = scaledImage(image, maxDimension: dimension)
// Get CGImage from UIImage - this is the key to stripping metadata // Get CGImage from UIImage - this is the key to stripping metadata
guard let cgImage = scaled.cgImage else { guard let cgImage = scaled.cgImage else {
throw ImageUtilsError.encodingFailed throw ImageUtilsError.encodingFailed
} }
guard let data = compressToBudget(cgImage) else {
// Use CGImageDestination to encode without metadata (same as macOS)
var quality = compressionQuality
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
throw ImageUtilsError.encodingFailed throw ImageUtilsError.encodingFailed
} }
jpegData = data
// Compress to target size if data.count <= FileTransferLimits.maxImageBytes || dimension <= minRetryDimension {
while jpegData.count > targetImageBytes && quality > 0.3 { break
quality -= 0.1
autoreleasepool {
if let next = encodeJPEG(from: cgImage, quality: quality) {
jpegData = next
}
} }
dimension = (dimension * dimensionRetryFactor).rounded(.down)
} }
guard let finalData = jpegData else { throw ImageUtilsError.encodingFailed }
let outputURL = try makeOutputURL(outputDirectory: outputDirectory) let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
try jpegData.write(to: outputURL, options: .atomic) try finalData.write(to: outputURL, options: .atomic)
return outputURL return outputURL
} }
} }
@@ -93,31 +110,16 @@ enum ImageUtils {
UIGraphicsEndImageContext() UIGraphicsEndImageContext()
return rendered ?? image return rendered ?? image
} }
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
guard let data = CFDataCreateMutable(nil, 0) else {
return nil
}
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
return nil
}
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
// By only specifying compression quality and no metadata keys,
// we ensure a clean JPEG with no privacy-leaking information
let options: [CFString: Any] = [
kCGImageDestinationLossyCompressionQuality: quality
]
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
guard CGImageDestinationFinalize(destination) else {
return nil
}
return data as Data
}
#else #else
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL { static func processImage(_ image: NSImage, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
return try autoreleasepool { return try autoreleasepool {
let scaled = scaledImage(image, maxDimension: maxDimension) var dimension = maxDimension
var jpegData: Data?
// See the iOS path: normal photos converge immediately; the loop
// only shrinks further for near-incompressible inputs so the
// output never overruns `maxImageBytes`.
while true {
let scaled = scaledImage(image, maxDimension: dimension)
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else { guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
throw ImageUtilsError.encodingFailed throw ImageUtilsError.encodingFailed
} }
@@ -139,20 +141,18 @@ enum ImageUtils {
guard let cgImage = context.makeImage() else { guard let cgImage = context.makeImage() else {
throw ImageUtilsError.encodingFailed throw ImageUtilsError.encodingFailed
} }
var quality = compressionQuality guard let data = compressToBudget(cgImage) else {
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
throw ImageUtilsError.encodingFailed throw ImageUtilsError.encodingFailed
} }
while jpegData.count > targetImageBytes && quality > 0.3 { jpegData = data
quality -= 0.1 if data.count <= FileTransferLimits.maxImageBytes || dimension <= minRetryDimension {
autoreleasepool { break
if let next = encodeJPEG(from: cgImage, quality: quality) {
jpegData = next
}
} }
dimension = (dimension * dimensionRetryFactor).rounded(.down)
} }
guard let finalData = jpegData else { throw ImageUtilsError.encodingFailed }
let outputURL = try makeOutputURL(outputDirectory: outputDirectory) let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
try jpegData.write(to: outputURL, options: .atomic) try finalData.write(to: outputURL, options: .atomic)
return outputURL return outputURL
} }
} }
@@ -172,6 +172,31 @@ enum ImageUtils {
scaledImage.unlockFocus() scaledImage.unlockFocus()
return scaledImage return scaledImage
} }
#endif
// When even the quality floor can't get an image under the byte budget,
// shrink the longest side by this factor and re-encode. Bounded below so
// the retry loop always terminates.
private static let dimensionRetryFactor: CGFloat = 0.75
private static let minRetryDimension: CGFloat = 256
/// Encodes `cgImage` to JPEG, stepping quality down toward
/// `targetImageBytes`. Shared by both platforms.
private static func compressToBudget(_ cgImage: CGImage) -> Data? {
var quality = compressionQuality
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
return nil
}
while jpegData.count > targetImageBytes && quality > 0.3 {
quality -= 0.1
autoreleasepool {
if let next = encodeJPEG(from: cgImage, quality: quality) {
jpegData = next
}
}
}
return jpegData
}
// Shared EXIF-stripping JPEG encoder for both iOS and macOS // Shared EXIF-stripping JPEG encoder for both iOS and macOS
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? { private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
@@ -193,7 +218,6 @@ enum ImageUtils {
} }
return data as Data return data as Data
} }
#endif
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL { private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
let formatter = DateFormatter() let formatter = DateFormatter()
@@ -1,6 +1,9 @@
import BitFoundation import BitFoundation
import CoreGraphics
import Foundation import Foundation
import ImageIO
import Testing import Testing
import UniformTypeIdentifiers
#if os(iOS) #if os(iOS)
import UIKit import UIKit
#else #else
@@ -58,6 +61,25 @@ struct ChatMediaPreparationTests {
#expect(prepared.packet.fileSize == UInt64(prepared.packet.content.count)) #expect(prepared.packet.fileSize == UInt64(prepared.packet.content.count))
#expect(prepared.packet.encode() != nil) #expect(prepared.packet.encode() != nil)
} }
/// A genuinely detailed photo must prepare to more than
/// `TransportConfig.wifiBulkMinPayloadBytes` (64 KiB); otherwise the Wi-Fi
/// bulk (AWDL) data plane is never offered in production because
/// `WifiBulkPolicy.shouldOffer` requires `payloadBytes > 64 KiB`.
/// Regression guard for the ~40 KB over-compression gap (PR #1385).
@Test
func prepareImagePacket_detailedImageExceedsWifiBulkThreshold() throws {
let sourceURL = try makeDetailedImageURL(dimension: 1200)
defer { try? FileManager.default.removeItem(at: sourceURL) }
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
defer { try? FileManager.default.removeItem(at: prepared.outputURL) }
// Comfortably above the 64 KiB Wi-Fi bulk offer threshold...
#expect(prepared.packet.content.count > TransportConfig.wifiBulkMinPayloadBytes)
// ...and still within the hard image cap for the BLE path.
#expect(prepared.packet.content.count <= FileTransferLimits.maxImageBytes)
}
} }
private func makeTemporaryImageURL() throws -> URL { private func makeTemporaryImageURL() throws -> URL {
@@ -87,6 +109,50 @@ private func makeTemporaryImageURL() throws -> URL {
return url return url
} }
/// Builds a random-noise PNG. Noise is incompressible, so the JPEG the prep
/// pipeline produces stays large a faithful stand-in for a detailed photo,
/// unlike a flat solid-color image which would compress to a few KB regardless
/// of dimension.
private func makeDetailedImageURL(dimension: Int) throws -> URL {
let width = dimension
let height = dimension
let bytesPerPixel = 4
let bytesPerRow = width * bytesPerPixel
var pixels = [UInt8](repeating: 0, count: bytesPerRow * height)
for index in pixels.indices {
pixels[index] = UInt8.random(in: 0...255)
}
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
guard let context = CGContext(
data: &pixels,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
), let cgImage = context.makeImage() else {
throw ChatMediaPreparationTestError.imageEncodingFailed
}
let url = FileManager.default.temporaryDirectory.appendingPathComponent("detailed-\(UUID().uuidString).png")
guard let destination = CGImageDestinationCreateWithURL(
url as CFURL,
UTType.png.identifier as CFString,
1,
nil
) else {
throw ChatMediaPreparationTestError.imageEncodingFailed
}
CGImageDestinationAddImage(destination, cgImage, nil)
guard CGImageDestinationFinalize(destination) else {
throw ChatMediaPreparationTestError.imageEncodingFailed
}
return url
}
private enum ChatMediaPreparationTestError: Error { private enum ChatMediaPreparationTestError: Error {
case imageEncodingFailed case imageEncodingFailed
} }