mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
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>
159 lines
6.2 KiB
Swift
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
|
|
}
|