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
@@ -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
}