Share to Stash (OS share extension)¶
How a link, image, or text shared from any app lands in the user's stash without opening Tomoda. Native-only; this page is the spec both platforms build to.
Mental model¶
iOS appex · Android Activity
The app never opens (Option B, "quiet save"). The only share-time UI is a branded confirmation card; tapping it deep-links into the app's existing triage sheet.
Architecture: managed prebuild → config plugins¶
frontend/ios and frontend/android are gitignored and regenerated by
expo prebuild, so every native change ships as an Expo config plugin (see
frontend/plugins/with-pod-fixes.js for the convention). Nothing native is
hand-edited in the generated projects.
| Piece | Where | Status |
|---|---|---|
| Android target | plugins/with-share-android.js + plugins/share/android/*.kt |
scaffold, needs a device/EAS build |
| iOS extension | targets/share/ (Swift + Info.plist + expo-target.config.js) via @bacons/apple-targets |
wired; needs a build to verify |
| Entitlements | app.config.js ios.entitlements (app) + targets/share/expo-target.config.js (extension) |
done |
| Token access | utils/tokenStore.ts (iOS Keychain mirror) |
done |
| Queue drain | services/shareQueue.ts + hooks/useShareQueueDrain.ts |
done, tested |
| Deep link | app/(tomoda)/plan.tsx ?openItem=<id> |
done |
Apple portal setup¶
Two App IDs, both with the App Groups capability enabled and the group
group.com.tomoda.app.share assigned:
com.tomoda.app(main app)com.tomoda.app.ShareExtension(the extension target, type App not App Clip)
Keychain Sharing is not a portal capability — there is no checkbox for it. It
works purely via the keychain-access-groups entitlement (already on both
targets). The only portal step is App Groups. After registering, run
eas credentials to re-sync both provisioning profiles.
The Team-ID-prefixed keychain group (33TV825K7S.com.tomoda.app.shared) is
hardcoded in utils/tokenStore.ts — the Team ID is a public identifier, not a
secret, so no env var is needed. The Swift side resolves the prefix from its own
entitlements at runtime.
Contracts¶
content_key— the native handler computes the same key the backend derives (backend/internal/services/plan/capture_parsers.go::deriveContentKey):"u:" + sha256(normalized-url), else"t:" + sha256(text), else an image identity hash. It is sent as theIdempotency-Keyheader andcontent_keybody field so retries and re-shares dedup server-side.- Token + API base — iOS reads the shared-access-group Keychain
(
utils/tokenStore.tsmirrors both the token and the current API base there on write), so the extension posts to whatever backend the app is on (local / devapi-dev.tomoda.life/ prodapi.tomoda.life), falling back to prod. Android reads an EncryptedSharedPreferences file (SharedAuth); the RN app must mirror the token +apiBasethere on Android. !!! note "Local (LAN) backend" The app's local dev backend ishttp://<LAN-IP>:8080(plain HTTP). The extension has its own Info.plist, so hitting a local HTTP backend needs an ATS exception on the extension too. Easiest is to test the extension against the HTTPS dev backend (an EASdevelopmentbuild) rather than local LAN. - Queue — when a capture can't send now (offline, missing token, deferred
image), the handler writes it to the App Group container (iOS) / WorkManager +
store (Android).
services/shareQueue.tsdrains it on foreground via theTomodaShareQueuenative module (list/remove), uploading images thenPOST /items.
Branded card spec (both platforms)¶
One spec, two native implementations (SwiftUI targets/share/ShareCard.swift,
Android views plugins/share/android/ShareCard.kt):
| Token | Value |
|---|---|
| Presentation | bottom sheet over a dim backdrop, rounded top (26pt), grabber handle |
| Surface | themed, follows system light/dark (Themes.ts): #17171A dark / #FFFFFF light, 1px border |
| Mark | adaptive Tomoda logo-t in a subtle badge — black on light, white on dark (iOS asset-catalog appearance; Android drawable/ vs drawable-night/) |
| Type | Bricolage Grotesque (the app typeface, Typography.ts), embedded in the extension bundle; theme text color |
| Content | just the title share_saved_title — no URL/preview, no checkmark (uniform for URL, text, and image shares) |
| Action | "share_go_to_plan →" button, bottom-right, inverted fill (white on dark / dark on light) |
| Localization | share_saved_title / share_go_to_plan across 39 locales (iOS .lproj/Localizable.strings, Android res/values-*/strings.xml), generated by scripts/gen-share-locales.js |
| Motion | slide up (~0.4s spring), hold ~4s, slide down (~0.22s) |
| Dismiss | auto after ~4s, or tap the backdrop |
| Go to plan | opens tomoda://plan (the plan page) |
| Layout | pill: [logo] Saved to Tomoda [✓], 18pt radius, 18×14 padding |
Build & release steps¶
- Apple portal (see "Apple portal setup" above): both App IDs get App
Groups + the group assigned. Keychain needs nothing here.
eas credentialsto re-sync both profiles. (done) - iOS + Android targets: both config plugins are wired; the adaptive
logo-tasset and the security-crypto dep + drawables are in place. task dev:ios:device(orexpo prebuild+ EAS build) → verify on device: share a link → card appears → item in stash; tap the card → app opens to the item.
Still needed for full coverage (image shares + Android auth):
TomodaShareQueuenative module (list/remove) so the RN drain (services/shareQueue.ts) reads the App Group / WorkManager queue — required for image shares.- Android token bridge: write the token +
apiBaseinto theTomodaShareAuthEncryptedSharedPreferences thatSharedAuthreads (iOS uses the Keychain mirror).
See Release (EAS) and Local Development.