Cap only migration-fallback media at Android's 256 fragments

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 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-26 10:53:25 +02:00
co-authored by Claude Opus 4.8
parent 301144289c
commit 14749e9454
3 changed files with 47 additions and 18 deletions
+18 -7
View File
@@ -1950,15 +1950,26 @@ final class BLEService: NSObject {
// Encode once using a small per-type padding policy, then delegate by type // Encode once using a small per-type padding policy, then delegate by type
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type) let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
// Cross-platform private-media v1 is bounded by Android's deployed // The 256-fragment ceiling exists to protect *current Android*
// 256-fragment receive cap. Run the same planner the scheduler will // receivers, which only ever receive private media over the directed
// use, after route application, for both encrypted and consented raw // raw-file migration fallback (they do not implement the encrypted
// migration sends. Reject before reserving a transfer slot or writing // 0x20 path). Encrypted private media (`noiseEncrypted`) is sent only to
// any fragment; public media is intentionally unaffected. // 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 iOSiOS photos in
// the ~120512 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, if let transferId,
let recipientPeerID = PeerID(hexData: packetToSend.recipientID), let recipientPeerID = PeerID(hexData: packetToSend.recipientID),
packetToSend.type == MessageType.noiseEncrypted.rawValue packetToSend.type == MessageType.fileTransfer.rawValue {
|| packetToSend.type == MessageType.fileTransfer.rawValue {
let compatibilityRequest = BLEOutboundFragmentTransferRequest( let compatibilityRequest = BLEOutboundFragmentTransferRequest(
packet: packetToSend, packet: packetToSend,
pad: padForBLE, pad: padForBLE,
@@ -741,7 +741,7 @@ struct PrivateMediaEndToEndTests {
} }
@Test @Test
func encryptedAndConsentedLegacySendsRejectAboveAndroidFragmentCap() async throws { func consentedLegacySendRejectsAboveAndroidFragmentCapButEncryptedDoesNot() async throws {
let root = FileManager.default.temporaryDirectory let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-fragment-cap-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("private-media-fragment-cap-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) } defer { try? FileManager.default.removeItem(at: root) }
@@ -790,14 +790,24 @@ struct PrivateMediaEndToEndTests {
allowLegacyFallback: true allowLegacyFallback: true
) )
let bothRejected = await TestHelpers.waitUntil( // The directed raw-file migration fallback (Android-style peer without
{ rejections.contains(encryptedID) && rejections.contains(legacyID) }, // the .privateMedia capability) still honors the 256-fragment ceiling.
let legacyRejected = await TestHelpers.waitUntil(
{ rejections.contains(legacyID) },
timeout: TestConstants.longTimeout timeout: TestConstants.longTimeout
) )
#expect(bothRejected) #expect(legacyRejected)
#expect(rejections.reason(for: encryptedID)?.contains("256") == true)
#expect(rejections.reason(for: legacyID)?.contains("256") == true) #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 _ = cancellable
} }
+13 -5
View File
@@ -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 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, 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 well below iOS's absolute inbound ceiling. That cap only applies to those
actual route-aware BLE fragment planner before both encrypted and consented receivers, which take private media exclusively over the directed raw-file
legacy sends and rejects any plan above 256 fragments with a visible failure. migration fallback (they do not implement the encrypted `0x20` path).
This fragment-count contract, rather than a guessed byte threshold, stays Private-media v1 therefore runs the actual route-aware BLE fragment planner
correct as route overhead changes. 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).