Skip to content

M5-06: Sharing from other apps

Status: ready | Depends on: M1-73, M4-22, M5-03, M5-04 | Milestone: M5

Goal

After this ticket a person can share text, links, images and files from another app into a chosen thread or channel (01 5.3 M6, 15 12, ADR 0056 item 2). On iOS and macOS a Share Extension in Swift and SwiftUI, without the Flutter engine, shows its own sheet with a preview, a comment field and the target list: the recently used targets with write permission that the app keeps in the App Group container (at most client.share.recent_targets) and a search for more (thread titles through searchThreadTitles, channels from the person's structure). It sends by itself through ClientKit: the file is uploaded with the upload protocol of 12 streamed from disk, then the message is created with a client_msg_id generated by the extension (or a new forum post through createThread with first_message). The extension authenticates with its own child session (client_component = 'share_ext') that the app creates with createExtensionSession and puts into the shared keychain group; the extension refreshes only that family, one refresh at a time (08 7.4, R-CL-07). Without network, or when the child session is missing or revoked, the extension writes the entry with its client_msg_id into the share inbox of the App Group; the app takes it over into its outbox at the next start, and on a duplicate the server returns the original. On Android a share activity with ACTION_SEND and ACTION_SEND_MULTIPLE for concrete MIME types opens the same target sheet in Flutter and sends through the app's outbox. A device test with patrol proves that an image from another app lands in the chosen thread, and ID-T16 runs with the real Share Extension: a simultaneous refresh of app and extension triggers no reuse detection. Why: sharing must not depend on the person opening the app later, and a share extension cannot open its own app (15 12).

Read first

  • docs/adr/0056-native-extensions-without-the-flutter-engine.md (items 2, 4, 5; "Confirmation": M5-06 and ID-T16).
  • docs/spec/15-client-architecture.md: 3 (ios/ and macos/ hold the ShareExtension), 6.1 (local table share_inbox), 6.2 (share_inbox migrated without loss), 7.3 item 2 (resync keeps share_inbox), 7.5 (outbox; uploads before the message), 8 (refresh: the share extension refreshes by itself with its own child session; if missing or revoked, the entry goes to share_inbox and the app creates a new child session at the next start), 9 (tokens for the extensions in the shared access group), 12 (all of it: mechanisms per platform, why the Apple extensions send by themselves, memory limit: stream from disk), 14 (file upload; JPEG for photos from the library), 21 (R-CL-06, R-CL-07).
  • docs/spec/01-product.md: 5.3 M6 (the sheet), 5.4 (Android: system share sheet into the same target selection), 3.4 (files and voice), 14 (client.share.recent_targets).
  • docs/spec/08-identity.md: 7.4 (extension sessions: own family, same DPoP key, at most one active child per parent and component, a new one replaces the old one, revoking the parent revokes the children), 15 (ID-T16).
  • docs/spec/12-files.md: the upload protocol (upload sessions, parts, resumption, completion), size limits, kind, image handling (7.1).
  • docs/spec/04-sync-protocol.md (core contract, read only): 13.2 (outbox and client_msg_id; a duplicate returns the original).
  • docs/api/openapi.yaml: createExtensionSession, refreshToken, createUpload, completeUpload, getUpload, abortUpload, createMessage (client_msg_id, attachments), createThread (first_message), searchThreadTitles, listSpaces and the channel list it gives.
  • docs/DECISIONS.md: D-W-100 and its extension to M5 and M6 (D-W-120), D-W-115; section 10 (format of D-M5-06-<n> rows) and the rows of M5-04.
  • Briefs (merged names win, D-W-100): M5-04-clientkit-nse.md (ClientKit: shared keychain, shared state, DPoP, nonce cache), M1-34-identity-sessions.md (StartChildSession, the refresh rules), M3-03-upload-sessions.md (upload protocol details), M3-07-attachments-invocation.md (attachments on messages), M3-14-search-coordinator.md (searchThreadTitles), M5-02-iphone-layout.md and M5-03-android-layout.md (runners, build configuration, the App Group and access group names), the briefs of M4-15 and M4-22 (outbox entries for uploads and messages).
  • Code on the base branch: apps/client/ios, apps/client/macos, apps/client/android, apps/client/ios/ClientKit, apps/client/lib, packages/local_store (share_inbox), packages/sync_engine (outbox).

This ticket touches no table, no action, no event and no server configuration key.

Fixed requirements that apply

Owner's fixed build rules (apply verbatim):

  1. No MVP. New from scratch and directly in the final state; stages are only the order.
  2. Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
  3. Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
  4. Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
  5. Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.

Rules for every brief:

  1. Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
  2. No en dash and no em dash in any file you write; the text-rules pre-commit hook rejects them, and the ARB test of M4-06 rejects them in interface texts. Use a hyphen or rephrase.
  3. White label: examples and test data use example.com and the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. Texts of the extension sheet come from packages/l10n (generated native strings, M5-04 Design note 6).
  4. Limits only through named configuration values: client.share.recent_targets from AppConfig, file_max_upload_bytes and message_max_attachments from InstanceLimits (D-W-115); code comments name the key, never the number.
  5. Specification first: a change to behaviour updates docs/spec/ in the same change. The core contract is not edited by this ticket.

Product requirements implemented here:

  1. The Apple extensions send by themselves through ClientKit (upload, then message with the extension's own client_msg_id) and never start a Flutter engine (ADR 0056 item 2).
  2. The extension uses only its own child session and refreshes only its own family; the app and the extension never share a refresh family (08 7.4; ID-T16).
  3. Without network or without a valid child session the entry goes to the share inbox with its client_msg_id; the app takes it over with the same ID; nothing is lost and nothing is sent twice (15 12; 04 13.2).
  4. Large files are streamed from disk, never held in memory (15 12).
  5. Only targets with write permission are offered; the list shows what the server delivered and what the person used before; a send to a target that lost write permission fails visibly, it is not hidden by client logic (15 1 principle 1).
  6. Photos from the library are uploaded as JPEG (15 14, ADR 0047); other files unchanged.
  7. Android: concrete MIME types, not */* (15 12).

Scope

Create:

  • apps/client/ios/ShareExtension/ and apps/client/macos/ShareExtension/ targets sharing one Swift source folder (apps/client/ios/ShareExtensionShared/ or a target in the ClientKit package; choose one and document it): the SwiftUI sheet (preview, comment, target list, search, send), the input item loading through NSItemProvider with file coordination and copying to the App Group container before upload (Design notes 1 and 2).
  • ClientKit additions: ExtensionSession (tokens of the child session in the shared keychain, refresh with a process-wide single flight, DPoP for every call), UploadClient (the upload protocol of 12 with parts from a file handle), MessageClient (createMessage, createThread with first_message), TargetStore (recent targets read from the App Group), ShareInbox (entries as files plus a manifest in the App Group) (Design notes 3 to 5).
  • apps/client/lib/share/: the app side: creating and renewing the child session through createExtensionSession after login and at each start when missing, writing the recent targets to the App Group on every successful send, taking over the share inbox at start into share_inbox and the outbox, and the Flutter target sheet used by Android (Design notes 4 and 6).
  • apps/client/android/app/src/main/kotlin/.../share/ShareActivity.kt (or an intent filter on MainActivity that opens a dedicated Flutter route; Design note 6) with the intent filters for the MIME types of Design note 6.
  • Tests (Tests), including the patrol device test and the ID-T16 test.
  • apps/client/e2e_test/share/id_t16_test.dart and its wrapper tools/e2e/id_t16.sh (Boundaries: starts the Compose smoke stack with the TLS sidecar, runs the test on the simulator, stops everything it started), outside integration_test/, because the platform jobs run integration_test/ through platform-tests.json without a server (as M4-34 and M4-36 keep their stack tests outside it).

Change:

  • Xcode projects: the extension targets, App Group and keychain access group entitlements (the same names as M5-04), NSExtensionActivationRule with the supported types and counts (message_max_attachments cannot be read at build time; the sheet enforces the server limit, the rule allows up to the platform default).
  • packages/local_store: only if share_inbox lacks a column the takeover needs (for example the source file path in the App Group); then a migration with the test that share_inbox survives (15 6.2).
  • apps/client/platform-tests.json: the swift test of the new ClientKit parts, the extension UI test, the Android share test.
  • Docs: apps/client/README.md (sharing on each platform, the inbox, how to run the device tests), docs/spec/15-client-architecture.md 12 (the App Group inbox format and the takeover), root CHANGELOG.md under [Unreleased], docs/DECISIONS.md section 10 rows D-M5-06-1 to D-M5-06-7.

Out of scope

  • Windows Share Target (01 lists it for later; 15 12 row Windows).
  • Sharing into a terminal, into a DM the person cannot see, or creating new channels from the sheet.
  • Voice recording (M5-07); push (M5-04, M5-05).
  • Changes to the core contract.

Boundaries

  • Work in a dedicated git worktree on the branch feat/M5-06-sharing; one writer per worktree.
  • Conventional Commits with the component as scope (for example feat(client-ios): ..., feat(clientkit): ..., feat(client-android): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md).
  • Never push to main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request of AGENTS.md. Never sign with a real identity or upload a build.
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Unit and integration tests bind only 127.0.0.1 with port 0 and use the fake server of M4-17 or URLProtocol stubs; the ID-T16 device test runs against the real server of the Compose smoke stack (M1-73) started by the test wrapper and stopped by it at the end. Because a published port of api reaches the server from the bridge gateway, which SEC-TLS-1 refuses (M1-73 D1), the wrapper adds a TLS sidecar with network_mode: service:api that terminates TLS for localhost with a test CA generated per run, publishes that port through the api service on 127.0.0.1 (a service with network_mode cannot publish ports itself; the wrapper picks a free port first, because server.public_url must name it before the stack starts), sets server.public_url to https://localhost:<port>, and lets the simulator trust the CA (xcrun simctl keychain <udid> add-root-cert); the overlay lives under infra/compose/test/ so the installation bundle never contains it. Temporary directories only via mktemp -d, removed at the end. Stop only processes you started, by PID; simulators by their UDID, emulators by their serial.
  • Never read or print secrets; never log a token, a DPoP proof or a shared file's content.
  • The Apple parts need macOS with Xcode and an iOS simulator runtime; the Android parts a JDK, the Android SDK and an emulator; the ID-T16 device test also Docker. Where a tool is missing, make apps-platform prints its SKIP line for that system; the report names where each test ran, and ID-T16 must have run at least once on the build host or the ticket stops with Verdict: BLOCKED <tool> missing.
  • Never lower a threshold, disable a gate, delete a test, raise repo.max_file_kb or extend quality/licenses/allowed.txt; never exclude hand-written code from a lint, format, coverage or doc gate.
  • No permission logic in the client (15 1 principle 1).
  • If the core contract or docs/api/ lacks something this ticket needs, stop with Verdict: BLOCKED <reason> instead of building a deviation (P-11).
  • This brief was written before milestone M1 closed (D-W-100, extended to M5 and M6 by D-W-120). Where the merged code of an earlier ticket names a package, type, function, file, provider or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with Verdict: BLOCKED <reason>.

Design notes

  1. Extension sheet (01 5.3 M6; 15 12; Ruling for docs/DECISIONS.md). SwiftUI: a header with Cancel and Send, a preview of the first item with name and size, a comment field, the target list (recent targets from TargetStore, newest first, at most client.share.recent_targets; a target is a thread, a channel's main timeline or "new post" in a forum channel), a search field that queries searchThreadTitles and filters the person's channels from listSpaces by name, and the note "Only targets with write permission." Send is disabled until a target is chosen. The sheet never shows message content of a target.

  2. Loading items (15 12, 14). NSItemProvider items are loaded as file URLs (or data written to a file for text and images that come as data), copied into the App Group container under a fresh folder per share, coordinated reads (NSFileCoordinator), never read fully into memory. Photos (public.image from the library) are converted to JPEG by the system (UIImage decode is a memory risk: use CGImageSource with a thumbnail-free full decode to JPEG through CGImageDestination, streaming where possible, and record the measured peak memory for a large photo) (12 7.1, ADR 0047). Sizes over file_max_upload_bytes (from the instance limits written to the App Group by the app) are rejected in the sheet with a reason.

  3. Child session (08 7.4; ID-T16; Ruling for docs/DECISIONS.md). The app calls createExtensionSession with client_component = share_ext after login and when the shared keychain has no valid child tokens at start; it stores the child's access and refresh tokens in the shared keychain group (AfterFirstUnlockThisDeviceOnly, not synchronizable); a new child replaces the old one on the server. ExtensionSession in ClientKit uses the access token while valid and refreshes the child family with the same DPoP key when it expires; the refresh is single flight inside the extension process and persists the new refresh token before using the new access token (the M4-09 rule). The app never refreshes the child family; the extension never touches the app's family. A 401 session_revoked or reuse rejection deletes the child tokens and turns the send into an inbox entry.

  4. Send and inbox (15 12; 04 13.2; Ruling for docs/DECISIONS.md). The extension generates client_msg_id (UUIDv7) when the person taps Send; uploads each file (create, parts, complete) and waits until upload_state = complete; then createMessage with client_msg_id, the comment and the attachments, or createThread with first_message for "new post". Any failure (no network, timeout, 5xx, 429, missing or revoked session) writes an inbox entry: a folder in the App Group with the files and a manifest.json holding client_msg_id, target, comment, created time, upload IDs already completed, never tokens. A 4xx other than 401 and 429 (for example 403 because write permission is gone, 413 too large) shows the reason in the sheet and keeps nothing. The extension completes the request when the send or the inbox write is done.

  5. Takeover by the app (15 6.1, 7.5). At start and when coming to the foreground the app reads the inbox manifests, inserts each into share_inbox and the outbox with the same client_msg_id (uploads before the message, 15 7.5), and deletes the folder after the outbox has taken the files into its own storage. A replay that the server answers with the original message (duplicate client_msg_id) completes the entry. The takeover is idempotent (an entry already in share_inbox is skipped).

  6. Android (15 12; 01 5.4). Intent filters for ACTION_SEND and ACTION_SEND_MULTIPLE with text/plain, image/jpeg, image/png, image/heic, image/webp, application/pdf and the other concrete types the upload accepts per 12 (list them explicitly; no */*). The activity hands the content URIs to Flutter through Pigeon, the Flutter route shows the same target sheet as the extension (recent targets from the app's database, search through the same operations), and the send goes through the app's outbox (uploads, then the message, 15 7.5). Content URIs are copied into the app's cache through ContentResolver streams before the activity finishes.

  7. Recent targets (01 14 client.share.recent_targets). The app writes the last client.share.recent_targets targets the person sent to (from any platform's send path) into the App Group as {target kind, id, display path}; display paths are channel and thread names the person can see. Accept this as the only names in the shared container (15 12 requires it); they are removed on logout and on access.changed that removes a target.

  8. Rulings for docs/DECISIONS.md (D-M5-06-<n>): the sheet and search sources (note 1); copy to the App Group and JPEG conversion (note 2); child session handling (note 3); send, failure classes and inbox format (note 4); idempotent takeover (note 5); Android MIME list and route (note 6); names of recent targets in the shared container and their removal (note 7).

Tests (write them first)

Swift (ClientKit XCTest): testExtensionRefreshesOnlyChildFamily (stubbed server: the refresh call carries the child's token; the app's family is never read), testSingleFlightRefresh (two concurrent calls, one refresh), testUploadStreamsFromFile (memory does not grow with file size beyond the part size in the test; parts sent in order; resumption after a failed part), testSendCreatesMessageWithClientMsgId, testFailuresGoToInbox (each failure class of Design note 4), test4xxShowsReasonAndKeepsNothing, testInboxManifestHasNoTokens.

Dart (apps/client/test/share/): child session created after login and at start when missing; takeover inserts entries with the same client_msg_id, skips duplicates, deletes folders after the outbox took them; recent targets written after a send and removed on logout and on access.changed; the Flutter target sheet (widget and golden tests in light and dark, text scaling 1 and 2).

Device tests (patrol, apps/client/integration_test/share/):

  • share_image_from_photos_test.dart (PLAN row; iOS simulator and Android emulator, fake server of M4-17): an image in the Photos (or Files) app is shared through the system share sheet into "server > Gateway: resume ..."; the fake server receives the upload and a message with the attachment in that thread; recent targets show this thread at the top on the next share.
  • share_offline_test.dart: the fake server is stopped; the extension writes an inbox entry; the app starts with the server back and the message arrives exactly once with the extension's client_msg_id.
  • share_android_mime_test.dart: a text/plain and a application/pdf share reach the sheet; an unsupported type is not offered (the merged manifest has no */*).
  • apps/client/e2e_test/share/id_t16_test.dart through tools/e2e/id_t16.sh (ID-T16 with the real Share Extension; Compose smoke stack of M1-73 on the loopback): the app and the extension both hold expired access tokens and refresh at the same moment (the test triggers the app refresh and a share in parallel); both succeed, the server writes no refresh_reuse, no session is revoked, the audit log has no reuse entry.

Commands that must pass, from the repository root:

  • cd apps/client/ios/ClientKit && swift test (on macOS)
  • cd apps/client && flutter analyze --fatal-infos && flutter test --coverage
  • on macOS with Xcode, simulators and Docker: make apps-platform (including the patrol share tests) and tools/e2e/id_t16.sh (ID-T16); with an Android emulator the Android share test; elsewhere the SKIP lines
  • make codegen-check, make arch, make lint, make fmt-check, make coverage, make licenses
  • make check

Acceptance criteria

  • Device test with patrol: an image from another app lands in the chosen thread on iOS and Android.
  • Recently used targets follow client.share.recent_targets; the search finds more targets; only targets with write permission are offered.
  • ID-T16 is green with the real Share Extension: simultaneous refresh of app and extension triggers no reuse detection.
  • Without network the entry goes to the inbox and arrives exactly once after the app's takeover; large files are streamed.
  • README, 15 12, CHANGELOG and the rulings are updated.
  • make check is green.

Definition of done

  • Code and tests as specified; documentation comments on public Swift declarations, KDoc on public Kotlin declarations, doc comments on public Dart symbols; comments explain why.
  • apps/client/README.md updated in English.
  • Rulings of Design note 8 recorded in docs/DECISIONS.md section 10.
  • Root CHANGELOG.md entry under 'Unreleased'.
  • An ADR only if you take an architecture decision beyond this brief (MADR 4 as in docs/adr/0000-template-madr-4.md, headings and text in English, next free number, make adr-index).
  • make check green.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN); on NEIN fix with a test per finding and have the delta checked again. Record both verdicts.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

  1. First line exactly Verdict: DONE or Verdict: BLOCKED <reason>.
  2. Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of make check.
  3. Review and red team verdicts and the findings fixed.
  4. Where each device test ran; the measured peak memory of the extension with a large photo and a large file.
  5. What is not proven (for example the memory limit of the extension on an old physical device).
  6. Follow-ups that could not be done in this scope, each with its reason, every difference to the names of this brief (D-W-100), and spec gaps found.

Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.