From 14749e94540a848980eaa38c2ccc65ee207b6249 Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 26 Jul 2026 10:53:25 +0200 Subject: [PATCH] Cap only migration-fallback media at Android's 256 fragments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The private-media preflight applied Android's 256-fragment reassembly cap to every directed media send, including the encrypted 0x20 path. That regressed iOS→iOS private photos in the ~120-512 KiB range, which work today: iOS reassembles up to 10,000 fragments and main compresses images to <=512 KiB. Encrypted private media is only ever sent to peers that advertised the .privateMedia capability (modern clients with the full receiver ceiling); current Android receives private media exclusively over the directed raw-file migration fallback. Restrict the 256-fragment cap to that fileTransfer fallback path so capable peers use the normal ceiling. Leaves a TODO(#1434) to negotiate an explicit per-peer fragment limit for a future Android client that adopts 0x20 but keeps a small reassembler. Co-Authored-By: Claude Opus 4.8 --- bitchat/Services/BLE/BLEService.swift | 25 +++++++++++++------ .../EndToEnd/PrivateMediaEndToEndTests.swift | 22 +++++++++++----- docs/PRIVATE-MEDIA-MIGRATION.md | 18 +++++++++---- 3 files changed, 47 insertions(+), 18 deletions(-) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index c67fb273..38a038e3 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -1950,15 +1950,26 @@ final class BLEService: NSObject { // Encode once using a small per-type padding policy, then delegate by type let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type) - // Cross-platform private-media v1 is bounded by Android's deployed - // 256-fragment receive cap. Run the same planner the scheduler will - // use, after route application, for both encrypted and consented raw - // migration sends. Reject before reserving a transfer slot or writing - // any fragment; public media is intentionally unaffected. + // The 256-fragment ceiling exists to protect *current Android* + // receivers, which only ever receive private media over the directed + // raw-file migration fallback (they do not implement the encrypted + // 0x20 path). Encrypted private media (`noiseEncrypted`) is sent only to + // peers that advertised the `.privateMedia` capability — modern clients + // that assemble up to the full receiver ceiling (see + // `BLEFragmentAssemblyBuffer`'s 10,000-fragment guard) — so forcing them + // down to Android's 256 cap would needlessly reject iOS→iOS photos in + // the ~120–512 KiB range that work today. Restrict the low cap to the + // migration fallback (directed `fileTransfer`); public media is + // unaffected. Run the same planner the scheduler will use, after route + // application, and reject before reserving a transfer slot or writing + // any fragment. + // TODO(#1434): negotiate an explicit per-peer fragment limit so a future + // Android client that adopts the encrypted 0x20 path but still caps its + // reassembler can advertise its own ceiling instead of relying on the + // capability/type proxy above. if let transferId, let recipientPeerID = PeerID(hexData: packetToSend.recipientID), - packetToSend.type == MessageType.noiseEncrypted.rawValue - || packetToSend.type == MessageType.fileTransfer.rawValue { + packetToSend.type == MessageType.fileTransfer.rawValue { let compatibilityRequest = BLEOutboundFragmentTransferRequest( packet: packetToSend, pad: padForBLE, diff --git a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift index 58e3b610..9b163223 100644 --- a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift +++ b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift @@ -741,7 +741,7 @@ struct PrivateMediaEndToEndTests { } @Test - func encryptedAndConsentedLegacySendsRejectAboveAndroidFragmentCap() async throws { + func consentedLegacySendRejectsAboveAndroidFragmentCapButEncryptedDoesNot() async throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("private-media-fragment-cap-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: root) } @@ -790,14 +790,24 @@ struct PrivateMediaEndToEndTests { allowLegacyFallback: true ) - let bothRejected = await TestHelpers.waitUntil( - { rejections.contains(encryptedID) && rejections.contains(legacyID) }, + // The directed raw-file migration fallback (Android-style peer without + // the .privateMedia capability) still honors the 256-fragment ceiling. + let legacyRejected = await TestHelpers.waitUntil( + { rejections.contains(legacyID) }, timeout: TestConstants.longTimeout ) - #expect(bothRejected) - #expect(rejections.reason(for: encryptedID)?.contains("256") == true) + #expect(legacyRejected) #expect(rejections.reason(for: legacyID)?.contains("256") == true) - #expect(tap.snapshot().isEmpty, "No outer packet or fragment may be exposed before size rejection") + + // Encrypted private media to a .privateMedia-capable peer is NOT forced + // down to Android's 256 cap: it uses the full receiver ceiling and + // proceeds to fragment/emit (a 130 KiB file exceeds 256 fragments). + let encryptedEmitted = await TestHelpers.waitUntil( + { !tap.snapshot().isEmpty }, + timeout: TestConstants.longTimeout + ) + #expect(encryptedEmitted, "Encrypted send to a capable peer must not be blocked by the Android cap") + #expect(!rejections.contains(encryptedID)) _ = cancellable } diff --git a/docs/PRIVATE-MEDIA-MIGRATION.md b/docs/PRIVATE-MEDIA-MIGRATION.md index fa0ba2f1..1f430797 100644 --- a/docs/PRIVATE-MEDIA-MIGRATION.md +++ b/docs/PRIVATE-MEDIA-MIGRATION.md @@ -100,8 +100,16 @@ temporary `0x09` alias. Ordinary Noise messages retain their 64 KiB limit. Current Android builds cap each reassembly at 256 fragments. Depending on the negotiated BLE packet size and routing overhead, that is roughly 110-120 KiB, -well below iOS's absolute inbound ceiling. Private-media v1 therefore runs the -actual route-aware BLE fragment planner before both encrypted and consented -legacy sends and rejects any plan above 256 fragments with a visible failure. -This fragment-count contract, rather than a guessed byte threshold, stays -correct as route overhead changes. +well below iOS's absolute inbound ceiling. That cap only applies to those +receivers, which take private media exclusively over the directed raw-file +migration fallback (they do not implement the encrypted `0x20` path). +Private-media v1 therefore runs the actual route-aware BLE fragment planner +before a consented legacy send and rejects any plan above 256 fragments with a +visible failure. Encrypted sends go only to peers that advertised the +`privateMedia` capability — modern clients that reassemble up to the full +receiver ceiling (10,000 fragments) — so they are not held to Android's cap and +iOS→iOS photos in the ~120-512 KiB range keep working. This fragment-count +contract, rather than a guessed byte threshold, stays correct as route overhead +changes. A future Android client that adopts `0x20` but still caps its +reassembler would need to negotiate an explicit per-peer fragment limit +(tracked as a #1434 follow-up).