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/andmacos/hold the ShareExtension), 6.1 (local tableshare_inbox), 6.2 (share_inboxmigrated without loss), 7.3 item 2 (resync keepsshare_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 toshare_inboxand 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 andclient_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,listSpacesand 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 ofD-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.mdandM5-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):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- 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).
- 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.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them, and the ARB test of M4-06 rejects them in interface texts. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. Texts of the extension sheet come frompackages/l10n(generated native strings, M5-04 Design note 6). - Limits only through named configuration values:
client.share.recent_targetsfromAppConfig,file_max_upload_bytesandmessage_max_attachmentsfromInstanceLimits(D-W-115); code comments name the key, never the number. - 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:
- The Apple extensions send by themselves through
ClientKit(upload, then message with the extension's ownclient_msg_id) and never start a Flutter engine (ADR 0056 item 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).
- 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). - Large files are streamed from disk, never held in memory (15 12).
- 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).
- Photos from the library are uploaded as JPEG (15 14, ADR 0047); other files unchanged.
- Android: concrete MIME types, not
*/*(15 12).
Scope¶
Create:
apps/client/ios/ShareExtension/andapps/client/macos/ShareExtension/targets sharing one Swift source folder (apps/client/ios/ShareExtensionShared/or a target in theClientKitpackage; choose one and document it): the SwiftUI sheet (preview, comment, target list, search, send), the input item loading throughNSItemProviderwith file coordination and copying to the App Group container before upload (Design notes 1 and 2).ClientKitadditions: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,createThreadwithfirst_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 throughcreateExtensionSessionafter 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 intoshare_inboxand 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 onMainActivitythat opens a dedicated Flutter route; Design note 6) with the intent filters for the MIME types of Design note 6.- Tests (Tests), including the
patroldevice test and the ID-T16 test. apps/client/e2e_test/share/id_t16_test.dartand its wrappertools/e2e/id_t16.sh(Boundaries: starts the Compose smoke stack with the TLS sidecar, runs the test on the simulator, stops everything it started), outsideintegration_test/, because the platform jobs runintegration_test/throughplatform-tests.jsonwithout 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),
NSExtensionActivationRulewith the supported types and counts (message_max_attachmentscannot be read at build time; the sheet enforces the server limit, the rule allows up to the platform default). packages/local_store: only ifshare_inboxlacks a column the takeover needs (for example the source file path in the App Group); then a migration with the test thatshare_inboxsurvives (15 6.2).apps/client/platform-tests.json: theswift testof the newClientKitparts, 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.md12 (the App Group inbox format and the takeover), rootCHANGELOG.mdunder[Unreleased],docs/DECISIONS.mdsection 10 rowsD-M5-06-1toD-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 ofAGENTS.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.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Unit and integration tests bind only
127.0.0.1with port 0 and use the fake server of M4-17 orURLProtocolstubs; 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 ofapireaches the server from the bridge gateway, which SEC-TLS-1 refuses (M1-73 D1), the wrapper adds a TLS sidecar withnetwork_mode: service:apithat terminates TLS forlocalhostwith a test CA generated per run, publishes that port through theapiservice on127.0.0.1(a service withnetwork_modecannot publish ports itself; the wrapper picks a free port first, becauseserver.public_urlmust name it before the stack starts), setsserver.public_urltohttps://localhost:<port>, and lets the simulator trust the CA (xcrun simctl keychain <udid> add-root-cert); the overlay lives underinfra/compose/test/so the installation bundle never contains it. Temporary directories only viamktemp -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-platformprints 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 withVerdict: BLOCKED <tool> missing. - Never lower a threshold, disable a gate, delete a test, raise
repo.max_file_kbor extendquality/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 withVerdict: 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¶
-
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 mostclient.share.recent_targets; a target is a thread, a channel's main timeline or "new post" in a forum channel), a search field that queriessearchThreadTitlesand filters the person's channels fromlistSpacesby 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. -
Loading items (15 12, 14).
NSItemProvideritems 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.imagefrom the library) are converted to JPEG by the system (UIImagedecode is a memory risk: useCGImageSourcewith a thumbnail-free full decode to JPEG throughCGImageDestination, streaming where possible, and record the measured peak memory for a large photo) (12 7.1, ADR 0047). Sizes overfile_max_upload_bytes(from the instance limits written to the App Group by the app) are rejected in the sheet with a reason. -
Child session (08 7.4; ID-T16; Ruling for docs/DECISIONS.md). The app calls
createExtensionSessionwithclient_component = share_extafter 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.ExtensionSessioninClientKituses 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. A401 session_revokedor reuse rejection deletes the child tokens and turns the send into an inbox entry. -
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 untilupload_state = complete; thencreateMessagewithclient_msg_id, the comment and the attachments, orcreateThreadwithfirst_messagefor "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 amanifest.jsonholdingclient_msg_id, target, comment, created time, upload IDs already completed, never tokens. A4xxother than401and429(for example403because write permission is gone,413too large) shows the reason in the sheet and keeps nothing. The extension completes the request when the send or the inbox write is done. -
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_inboxand the outbox with the sameclient_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 (duplicateclient_msg_id) completes the entry. The takeover is idempotent (an entry already inshare_inboxis skipped). -
Android (15 12; 01 5.4). Intent filters for
ACTION_SENDandACTION_SEND_MULTIPLEwithtext/plain,image/jpeg,image/png,image/heic,image/webp,application/pdfand 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 throughContentResolverstreams before the activity finishes. -
Recent targets (01 14
client.share.recent_targets). The app writes the lastclient.share.recent_targetstargets 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 onaccess.changedthat removes a target. -
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'sclient_msg_id.share_android_mime_test.dart: atext/plainand aapplication/pdfshare reach the sheet; an unsupported type is not offered (the merged manifest has no*/*).apps/client/e2e_test/share/id_t16_test.dartthroughtools/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 norefresh_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 thepatrolshare tests) andtools/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 licensesmake 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 checkis 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.mdupdated in English.- Rulings of Design note 8 recorded in
docs/DECISIONS.mdsection 10. - Root
CHANGELOG.mdentry 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 checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - 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. - Review and red team verdicts and the findings fixed.
- Where each device test ran; the measured peak memory of the extension with a large photo and a large file.
- What is not proven (for example the memory limit of the extension on an old physical device).
- 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>.