Skip to content

feat(mobile): attach images from the phone's photo library - #552

Merged
Tryanks merged 1 commit into
mainfrom
feat/mobile-attachments
Sep 28, 2026
Merged

Tryanks merged 1 commit into
mainfrom
feat/mobile-attachments

Conversation

@Tryanks

@Tryanks Tryanks commented Sep 28, 2026

Copy link
Copy Markdown
Owner

What changes

Mobile photo attachments. A + button at the left of the phone composer row opens a bottom sheet with Photo library, which calls the platform's own picker: PHPickerViewController on iOS, MediaStore.ACTION_PICK_IMAGES on Android 13+ and ACTION_OPEN_DOCUMENT below. Neither needs a permission. The picker returns bytes through a new ClientHost::pick_images (crates/client/src/host.rs), wired in NativeClientHost with the same request/callback pattern as QR scanning. Desktops keep paste and drop; the button only renders when the host reports a picker.

Fitting on the device (fit_for_upload, crates/ui/src/composer/components/images.rs): images longer than 2048 px on either side are resized, non-wire formats become PNG, JPEG stays JPEG, GIF is never re-encoded. Bytes that already qualify pass through untouched. iOS delivers HEIC as JPEG via .compatible representation.

Link-aware transfer policy (crates/ui/src/attachments.rs, transfer_verdict):

Link Behaviour
Local / LAN only the existing 10 MiB / 8 images limits
Tunnel (direct across the internet) above the device limit → confirm dialog
Relay, or unknown above the device limit → refused with a message

The limit is a device preference (ClientPreferences::remote_attachment_limit_mib, default 2 MiB) with a row in Settings → General.

Fixes found on the way:

  • crates/client/src/outgoing.rs capped one line at 8 MiB while the transport allows 16 MiB; a 10 MiB attachment encodes to ~13 MiB, so images over ~6 MiB failed with queue_full over any remote connection. The cap now has one owner, tcode_protocol::MAX_LINE_BYTES, used by both the traverse wire and the client queue.
  • The host never checked SaveAttachment: it now rejects a dir outside its attachments root or containing .., an extension that is not plain alphanumeric (the generated file name embeds it), and bytes over the per-image limit (crates/runtime/src/pipe.rs, AppState::accepts_attachment).

Abstractions

  • TransferLink/TransferVerdict hide the path-kind → policy mapping; WorkspaceStore::attachment_link is the one place ConnectionState is read for it.
  • PreparedImage replaces a tuple; owns_image_load replaces a three-way condition duplicated at two completion points.
  • is_safe_extension and attachments_root live in core / services next to the limits and layout they belong to.
  • transcode_image_to_png is folded into fit_for_upload (its only caller).

Tests

  • pipe_p4b_tests: extends the SaveAttachment round trip with the four rejected shapes (outside root, .., traversal in ext, oversized).
  • attachments::tests: the verdict table above.
  • images::tests: fitting bounds the long edge, keeps qualifying bytes, converts BMP, leaves GIF alone, rejects non-images.
  • core::attachments: extension admission.
  • Native host preference round-trip extended with the new field; locale parity covers the new strings in en and zh-CN.

Checks run

  • cargo fmt --all --check, cargo clippy --workspace --all-targets --locked -- -D warnings, cargo nextest run --workspace --locked (950 passed), cargo-machete: clean.
  • cargo check -p tcode-ios --target aarch64-apple-ios-sim, cargo ndk -t arm64-v8a check -p tcode-android, cargo check -p tcode-web --target wasm32-unknown-unknown, all with -D warnings: clean.
  • Swift: swiftc -typecheck against the iOS 27 simulator SDK; Java: gradlew :app:compileReleaseJavaWithJavac: clean.

Not verified

  • No on-device run. The picker flows (PHPicker delegate, Android content URI reads, the + layout at phone width) are established only by the type checks above; the + does not appear in --example phone because that shell has no picker-capable host.
  • The browser client is unchanged and still limited by its 64 KiB WebSocket frame; it never attached images before either.

Phones and tablets get a "+" at the left of the composer row that opens the
system picker (PHPicker on iOS; the photo picker on Android 13+, the document
picker below). Selected images are fitted on the device — long edge capped at
2048 px, non-wire formats and HEIC converted — before they go to the machine.

Sending is gated by how the device reaches the machine: LAN and local are
unrestricted; over a punched internet path an image above the device's
"Attachment limit over the internet" setting (2 MiB default) asks first; over
a relay it is refused, since the relay is shared and the main stream would
stall behind it.

Also fixed on the way: the client outgoing queue capped a line at 8 MiB while
the wire allows 16 MiB and an attachment can encode to ~13 MiB, so images over
about 6 MiB were rejected as "queue full" over any remote connection. The line
cap now has one owner in tcode-protocol. The host also validates saved
attachments: below its attachments root, no traversal, a plain extension, and
at most the per-image limit.
@Tryanks
Tryanks merged commit f4f32b9 into main Sep 28, 2026
7 checks passed
@Tryanks
Tryanks deleted the feat/mobile-attachments branch September 28, 2026 22:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant