Skip to content

M4-22 Files, images and voice messages on the desktop

No Date Ruling Reason
D-M4-22-0 2026-10-22 Port to main: the ticket was built on int/m1-wave48 with feat/M3-05-file-download stacked and reviewed there (red team JA); it lands as feat/M4-22-files-desktop-main from origin/main with only its own changes (the squash of d929992..4eee358), now that M3-05 and M4-20 are on main. Adaptations to main: the spec change of 15 7.5 and 15 14 is made in 15-client-architecture.md; the changelog entry and these rulings are fragments; the localization keys of the ticket are added to the ARB files of main and the localizations and pubspec.lock are regenerated; the license overrides of the new packages are appended to osv-scanner.toml; the local review note under .review/ is not taken over. The file operations of docs/api/openapi.yaml are the ones of M3-03 and M3-05 (UploadCreate.sha256 is required lowercase hex, not Base64 as 12 3.1 says; the client follows the API). Differences to the brief's names, following the merged code: the instance folder is <application support>/instances/<instance id> (M4-18), so the cache lies in instances/<instance id>/files; the composer is composer/thread_composer.dart; the session column frame is session_column/session_column.dart; the voice controller lives in voice/voice_button.dart; providers are in files/providers.dart. Merged names win over the brief (D-W-100, D-W-114); one pull request per ticket on main.
D-M4-22-1 2026-10-20 Attach after completion: an upload entry is confirmed when completeUpload answered 200 (12 3.4), which releases the message; the message holds one slot file:<key> per attachment that the outbox fills from the upload's result (D-M4-15-6), and the wrapped createMessage executor turns the slots into attachment_file_ids. An upload confirmed before the message was written gives its file ID through UploadProgress of the run. Parts: at most two in flight per upload (UploadOps.parallelParts), each read alone from disk with Content-Digest; after any failure the entry is retried by the outbox with the same Idempotency-Key, so createUpload returns the same session and only the parts getUpload did not confirm are sent. completeUpload uses a second key derived from the entry ID. Uploads send in their own lane upload:<entry id>, so a long upload never holds the writes of the thread written before its message; the message itself waits in the thread's lane until its uploads are confirmed, and the writes written after it wait behind it (04 13.2 item 2). part_conflict, 410 gone and other final errors fail the entry with the reason; "Upload again" sends it with a new ID and so a new session. Brief Design notes 1 and 2; 12 3.2 item 4 leaves the parallelism to the client, and two parts keep one slow part from stalling an upload without flooding the server.
D-M4-22-2 2026-10-20 Hashing: the SHA-256 of the whole file is computed in a background isolate over a stream of the file (sha256OfFile) when the entry is created; parts are read one at a time with RandomAccessFile, never the whole file in memory. Downloads stream into the cache while their SHA-256 is computed and are kept only when it equals Repr-Digest. Brief Design note 1; 12 9.3.
D-M4-22-3 2026-10-20 Tickets: audio and video take a new createFileTicket per playback; the URL is resolved against the instance address, handed to the player and kept in no log, database row or file (test download_test.dart). Downloads of the app itself never use a ticket: they go through the REST client with DPoP. Brief Design note 3; 12 9.2 item 4.
D-M4-22-4 2026-10-20 Packages, each under the license gate: record 7.1.2 (BSD-3-Clause) for AAC-LC mono recording, audioplayers 6.8.1 (MIT) for voice and audio playback on macOS and Windows with the rates 1, 1.5 and 2, video_player 2.14.1 (BSD-3-Clause) for video through a ticket (macOS; on Windows video_player has no implementation, so a video there plays only after "Open"), file_selector 1.1.0 (BSD-3-Clause) for the open and save dialogs, desktop_drop 0.8.4 (Apache-2.0; 0.9 needs cross_file 0.4, which file_selector does not accept) for drag and drop and pasteboard 0.5.0 (Apache-2.0) for pasted files and images. just_audio was not taken because it has no Windows implementation of its own. Brief Design note 4: choose by the license gate and the targets.
D-M4-22-5 2026-10-20 Cache layout: thumbnails at <instance folder>/files/<file id>/<variant>, the original at <instance folder>/files/<file id>/original/<file name> so the system opens it with the app of its extension; downloads in progress in files/.partial, files the app writes before an upload (recordings, pasted images) in <instance folder>/outgoing and deleted after their upload. The rows stay in file_cache of the encrypted database. FileCache is the FileCacheEraser of the engine (access removal deletes the bytes), runs the CacheCleaner of M4-12 once per start and deletes the paths it returns, and the account wipe deletes the instance folder with everything in it. Brief Design note 5; 15 6.3.
D-M4-22-6 2026-10-20 States: a stored file maps to the views of 12 2 by upload_state and scan_status ("being checked", "Upload failed", "blocked", ready, and a tombstone for a deleted file, recognised by the redacted fields of 04 2.2 rule 5); an upload entry maps to waiting, uploading with the confirmed share, "Upload failed" with the reason, and canceled for 410 gone; a confirmed upload whose message waits shows "being checked". A thumbnail that answers 409 file_not_ready shows "being checked" and loads again on the next file.updated of the file; 409 file_blocked shows "blocked". A failed upload is not listed as a refused write of its own; it shows on its message and in the composer. 12 2; brief Goal and Design note 3.
D-M4-22-7 2026-10-20 The internal reference form of an attachment in the Markdown content is ![alt](attachment:<file_id>); the resolver of DsMarkdown turns it into the thumbnail of that file only when the file is one of the message's own servable images, and every other image stays the text of M4-07. A message may consist of attachments only: DsComposer gained hasAttachments, which enables sending without text. M4-07 Design note 2 leaves the form to this ticket; SEC-OUT-2.
D-M4-22-8 2026-10-20 Voice: the recorder asks for the microphone at the first start; a denied permission shows how to allow it in the system settings. The recording stops by itself at voice_max_duration_ms and then waits to be sent or discarded. A voice message is a message with kind = voice, no text, no clear_draft (the typed draft stays) and exactly one audio/mp4 attachment. "Transcribe" shows for a transcript that is none or failed; the server's answer decides, and 409 conflict with transcription_unavailable shows a short text. The voice switch voice.enabled of 01 3.4 is not read: the server refuses when it is off. 15 13; 12 8; 15 1 principle 1 (no own derivation in the client).
D-M4-22-9 2026-10-20 The fake server serves the file operations from rest/file_handlers.dart over world_files.dart; RestFake reads application/octet-stream bodies as bytes, accepts a file ticket instead of DPoP on downloadFile and downloadThumbnail, and FakeResponse.bytes takes a status and headers; FaultPlan.onRequest takes a reason. Presigned parts go to PUT /objectstore/<file_id>/<n>, which refuses a request with Authorization. Brief Scope (handler registry of M4-17) and Tests.
D-M4-22-10 2026-10-20 The integration tests run headless with -d flutter-tester (no platform build needed), and on macOS and Windows in CI (make apps-platform). A headless run checks the flows without a desktop toolchain; the platform runs stay in CI.
D-M4-22-11 2026-10-20 Findings of the review and the red team: attached files are copied into <instance folder>/outgoing/<key>/ and the copy is uploaded and deleted after the upload, so an upload resumes after a restart (a sandboxed macOS app loses access to a picked file when it quits); the local file is read only while parts are missing, so a session the server completed before the app quit is confirmed with getFile instead of a second completion; a file that cannot be read fails its upload with file_unreadable instead of retrying; api_client decodes the JSON problem of a binary response before the auth interceptors (BinaryProblemInterceptor), so downloads get the DPoP nonce retry, the refresh and the revocation like every other request; the logger of audioplayers is off; a second start or stop of a recording waits for the first; a failed Save as or Open shows its reason; and message.deleted or a redacted message deletes the file cache rows of the message's files and their bytes after the commit (MessagesDao.tombstoneWithFiles, 20 4.7). Review and red team of M4-22 (Majors reproduced).
D-M4-22-12 2026-10-20 The goldens of files_golden_test.dart pass with a share of differing pixels up to _tolerance of that file: the close icon of a tile is anti-aliased one colour step apart on macOS and on the Linux runner, so no exact CI golden matches both hosts. The tolerance is set for this file only; the other goldens keep the exact comparison. build-verify on the build branch showed the one-pixel difference in ci/attachment_tiles_dark.png; a golden regenerated on the runner fails on macOS by the same pixel.
D-M4-22-13 2026-10-22 pubspec.lock keeps video_player_avfoundation at 2.12.1, the version the ticket was built and checked with. A fresh resolution picks 2.12.2, whose Swift sources do not compile on the macOS runner (AVFactory.swift: extra argument isolation in call), so the macOS build fails. A lock entry is the tested version; a newer transitive version is taken only together with a macOS build that passes.
D-M4-22-14 2026-10-22 Open refuses a file whose name ends with the extension of a program or a script (executableExtensions in download_service.dart, for example .exe, .bat, .ps1, .app, .command) with open_refused and never hands it to the system; Save as stays. The cache names a file in one valid segment on every desktop system (no leading or trailing dots and spaces, a Windows device name gets the prefix _). A file of another person must not run by a click on Open (PR review of M4-22).