Skip to content

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

Any app share sheet
Native handler
iOS appex · Android Activity
↓ read token (shared Keychain / prefs)
POST /items Idempotency-Key = content_key
↓ on fail / deferred image
Queue App Group · WorkManager
Branded card tap → tomoda://plan
↓ app next foreground
Drain services/shareQueue.ts
Quiet save: URL/text POST in the background, images finish on next foreground. The app never opens unless the user taps the card.

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 the Idempotency-Key header and content_key body field so retries and re-shares dedup server-side.
  • Token + API base — iOS reads the shared-access-group Keychain (utils/tokenStore.ts mirrors both the token and the current API base there on write), so the extension posts to whatever backend the app is on (local / dev api-dev.tomoda.life / prod api.tomoda.life), falling back to prod. Android reads an EncryptedSharedPreferences file (SharedAuth); the RN app must mirror the token + apiBase there on Android. !!! note "Local (LAN) backend" The app's local dev backend is http://<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 EAS development build) 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.ts drains it on foreground via the TomodaShareQueue native module (list / remove), uploading images then POST /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

  1. Apple portal (see "Apple portal setup" above): both App IDs get App Groups + the group assigned. Keychain needs nothing here. eas credentials to re-sync both profiles. (done)
  2. iOS + Android targets: both config plugins are wired; the adaptive logo-t asset and the security-crypto dep + drawables are in place.
  3. task dev:ios:device (or expo 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):

  1. TomodaShareQueue native module (list/remove) so the RN drain (services/shareQueue.ts) reads the App Group / WorkManager queue — required for image shares.
  2. Android token bridge: write the token + apiBase into the TomodaShareAuth EncryptedSharedPreferences that SharedAuth reads (iOS uses the Keychain mirror).

See Release (EAS) and Local Development.