Files
bitchat/bitchatTests/ChatMediaPreparationTests.swift
T
jackandClaude Fable 5 9b8256bf72 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>
2026-07-07 13:34:20 +02:00

159 lines
6.2 KiB
Swift

import BitFoundation
import CoreGraphics
import Foundation
import ImageIO
import Testing
import UniformTypeIdentifiers
#if os(iOS)
import UIKit
#else
import AppKit
#endif
@testable import bitchat
struct ChatMediaPreparationTests {
@Test
func prepareVoiceNotePacket_buildsEncodedAudioPacket() throws {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url, options: .atomic)
defer { try? FileManager.default.removeItem(at: url) }
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
#expect(packet.fileName == url.lastPathComponent)
#expect(packet.mimeType == "audio/mp4")
#expect(packet.fileSize == 5)
#expect(packet.encode() != nil)
}
@Test
func prepareVoiceNotePacket_rejectsOversizedAudio() throws {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-too-large-\(UUID().uuidString).m4a")
try Data(repeating: 0x55, count: FileTransferLimits.maxVoiceNoteBytes + 1).write(to: url, options: .atomic)
defer { try? FileManager.default.removeItem(at: url) }
#expect(throws: ChatMediaPreparationError.voiceNoteTooLarge(bytes: FileTransferLimits.maxVoiceNoteBytes + 1)) {
try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
}
}
@Test
func prepareImagePacket_rejectsInvalidImage() throws {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("invalid-\(UUID().uuidString).jpg")
try Data("not-an-image".utf8).write(to: url, options: .atomic)
defer { try? FileManager.default.removeItem(at: url) }
#expect(throws: ImageUtilsError.invalidImage) {
try ChatMediaPreparation.prepareImagePacket(from: url)
}
}
@Test
func prepareImagePacket_buildsEncodedJpegPacket() throws {
let sourceURL = try makeTemporaryImageURL()
defer { try? FileManager.default.removeItem(at: sourceURL) }
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
defer { try? FileManager.default.removeItem(at: prepared.outputURL) }
#expect(prepared.packet.fileName == prepared.outputURL.lastPathComponent)
#expect(prepared.packet.mimeType == "image/jpeg")
#expect(prepared.packet.fileSize == UInt64(prepared.packet.content.count))
#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 {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("image-\(UUID().uuidString).png")
#if os(iOS)
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 64))
let image = renderer.image { context in
UIColor.systemTeal.setFill()
context.fill(CGRect(x: 0, y: 0, width: 64, height: 64))
}
guard let data = image.pngData() else {
throw ChatMediaPreparationTestError.imageEncodingFailed
}
#else
let image = NSImage(size: NSSize(width: 64, height: 64))
image.lockFocus()
NSColor.systemTeal.setFill()
NSRect(x: 0, y: 0, width: 64, height: 64).fill()
image.unlockFocus()
guard let tiff = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiff),
let data = bitmap.representation(using: .png, properties: [:]) else {
throw ChatMediaPreparationTestError.imageEncodingFailed
}
#endif
try data.write(to: url, options: .atomic)
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 {
case imageEncodingFailed
}