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 local device 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, regenerate
both provisioning profiles in Xcode (or the Apple Developer portal) so they
pick up the group, then rebuild.
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 (a local device build pointed at dev) 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. Regenerate both provisioning profiles in Xcode / the Apple Developer portal. (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+ a local device 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 Native Release and Local Development.