The mobile app lives in mobile/ and is the @orbyn/mobile workspace of the monorepo. It shares
types, validation, and the API client with the web app through @orbyn/core and
@orbyn/api-client.
Pending mobile commands are saved for the account that created them before sending. Signing out retains that account's pending work on the device; another account cannot see or send it. Signing back into the same account can resume it after current server permission checks. If a previous send has an unknown outcome, new credentials or an expired receipt window require checking the result before explicit retry, which can otherwise duplicate an already saved change. Attempted commands keep their payload and key; later edits receive a separate key. Sync & devices provides explicit discard; removing a local copy cannot undo a request already accepted by the server.
An older queue has no verified account owner. Its original contents are preserved for recovery and excluded from normal lists and automatic sending. Sync & devices shows a generic held-changes notice and offers confirmed copy or discard. It does not automatically adopt those changes into the current account. Storage failures remain visible and block unsafe sending until retried.
The queue and session formats must ship together. Rollback must keep their ownership and recovery behavior; arbitrary older binaries are unsupported. See the architecture consolidation record for writer control and remaining native rollout qualification.
The shared item store begins with an authorized primary snapshot and uses best-effort incremental pages between reconciliations. Manual refresh, observed access changes and local write receipts force reconciliation; active connected polling schedules it at least every five minutes. Offline or failed requests retain valid data and retry, so the interval is not an offline freshness promise. Failed companion reads preserve their last successful values unless access has been revoked. Known team loss prunes items before awaiting storage/network work and removes the owner's saved snapshot if it contains lost teams. Serialized, session-fenced cache writes prevent an older save from resurrecting that snapshot. An unavailable cache purge reports its failure; native storage behavior still requires installed-device qualification.
Expo web coordinates planner snapshot reads, writes and purges across cooperating
same-origin runtimes with the exclusive orbyn-planner-cache Web Lock. The whole
read/remove sequence holds that lock; a per-tab queue alone does not protect
localStorage. Native AsyncStorage keeps its existing per-runtime queue.
Without Web Locks, optional web snapshot caching is disabled: cache reads return
no snapshot and writes/clears/purges leave the shared key untouched. Lock rejection
has no unlocked fallback; purge errors remain visible while connected reads can
continue. This does not disable the in-memory planner or server reads. Reload older
Expo web tabs during rollout because older bundles do not acquire the lock.
The bottom bar contains Today, Tasks, Calendar, Assistant and Workspace. Notifications are available from the bell in the header. Workspace provides direct access to Planning, Projects, Docs, Agenda and the other workspace tools. Tapping the tab you are already on scrolls back to its top and refreshes, as in Instagram and Facebook; on Assistant it jumps to the newest message instead.
- Assistant uses Chats for personal/project, pinned and recent conversations, and Activity for Background, Overnight and Upcoming. Message options supports Copy, Details and excerpt selection into the existing composer. The keyboard-aware composer preserves typed text; selecting an excerpt does not send it. Unified timeline behavior depends on the server rollout flag. See Assistant.
- Documents opens on a collection, with Browse library revealing Pages, Notes, Favorites and expandable folders. Folder children open directly; View folder narrows the collection. New documents and notes inherit the selected folder. Sort switches between last edited and title A–Z; search looks across all pages and notes. The web client keeps this library beside the editor and collapses it behind Browse library on narrow screens; on wide screens Hide library gives the page the full width, and the web sidebar collapses to an icon rail.
- Import file (Documents) picks a PDF, Word file or photo of notes with
expo-document-pickerand uploads it. Scan notes takes a photo with the camera (expo-image-picker) when the server can read photos (GET /imports/capabilities). Files shared to Orbyn from another app (the iOS share extension and Android share targets fromexpo-sharing, set up inapp.json) are imported too, and Docs opens on Uploads. Uploads rows offer Make cards (it opens Study's suggestions) and Import again after a failure. Tapping an "import ready" notice opens the page. The page lands in the Uploads collection, which also shows files still being read, with progress. Move to folder files a page and takes it out of Uploads. The picker is a native module, so iOS and Android need a new development or EAS build after this change: the picker, the camera, the share extension, haptics and file saving are all native. The web build works as is. - Study matches the web: three ways to get your first cards, pages with cards as compact rows, Study ahead and Quiz me when nothing is due, a 7-day forecast, and each exam's projection. Maths in cards reads as text. While reviewing, swipe a shown card right for Good or left for Again, with a light haptic tap.
- Settings → Passkeys lists and removes passkeys; Add a passkey opens the web app in a browser
sheet (passkeys belong to Orbyn's web address). Sync & devices can forget a device. Exporting a
page (every format) and an admin's export of a user's data are saved through the share sheet
(
expo-file-system,expo-sharing). Admin → Storage is on the phone too. - Document cards expose Open, Star and Folder separately to accessibility services. Document titles grow as they wrap, including while editing.
- Projects opens on Tasks, with Notes, Timeline, Decisions and History in separate views. Promises across every project sit in one collapsible row above the list; a decision that no task delivers yet says so and offers Make a task.
- Planning groups settings into collapsible sections and keeps Save at the bottom. Collapsing a section preserves its draft values.
- Calendar starts in Day unless a view was saved. Phone month grids use activity dots; wider layouts retain event bars. Switching to Week or Month keeps the date picker visible.
- Choice controls wrap when their available width cannot provide 44-point targets.
- One + sits in the header on every tab. A tap runs the favourite (New task until another is
chosen); a long press opens the + sheet: New task, New page, From template, Scan notes, New
project, Plan my day, Start focus and Ask assistant, with Arrange at the bottom to reorder
rows, hide the ones never used and star the one a tap runs (kept on the device,
packages/core/src/planner/create-actions.ts). - Writing a page: the line being typed gets one row of icons on the keyboard (the sheet
docks it just above it): Undo, Redo, Aa (the kind of line), Bold, Italic, Highlight, Link
(a web address for now), To-do (a to-do with words becomes a task of its own: Make task),
Indent, Outdent, Comment, Ask (the assistant, on the chosen words or the whole line), ⋯ (Move
up, Move down, Comment on some words, Delete line) and Hide keyboard last, which puts the line
away. The style the caret is in is tinted. Undo and Redo work across the page, not only the
line (
packages/core/src/tasks/undo.ts,line-toolbar.ts). - A page's header is Back, its title (once the page's own title has scrolled away), Info and ⋯. Info holds how you're working on the page (Editing, Suggesting, Viewing), what it belongs to, its tags, "still true?", its size, who else is here, and Show history. ⋯ holds Ask about this page, Share…, Export…, History, Save as template and Move to Trash.
- Share is in the ⋯ of pages (the link, a Markdown file or a PDF), tasks and projects (the
link). Links are the web app's (
/app/doc/<id>,/app/task/<id>,/app/project/<id>); the web app on a phone's browser has the same Share where the browser has a share sheet. - Save to Orbyn: text and links shared from another app open a sheet with the link's title and
site, the last three places used as chips, and where it goes: an Inbox task "Read: <title>"
with the link, today's agenda (under Notes), a page, a new page in a folder, or a project; one
Save (
POST /capture,POST /capture/previewfor the title). Files still go to Uploads.
Native checks used Expo Go on the iPhone 17 simulator. Edit/save checks used an isolated copy with in-memory API fixtures, not the deployed account: document title editing and returning to the list, independent starring, project section switching, planning working-day changes and saving, chat composition and replies, and calendar view/date navigation. The software keyboard was checked with the assistant composer. Light appearance and a constrained 320-point-wide dark layout were inspected. Fixtures verify client behavior, not backend persistence or live AI-provider responses.
The library follow-up was checked with isolated fixtures: web folder expansion, filing a page, A–Z sorting and opening the editor beside the sidebar; native library navigation, selecting a folder, creating a page in it and sorting at a constrained 320-point width in dark appearance. The web production build also passed. Folders remain one level deep, with pages as children.
The mobile TypeScript check and iOS/Android bundle exports are the build gates. Android device behavior, physical-device keyboards and push delivery need their own device checks; an export is not a substitute for those checks.
From the repository root:
npm install
npm run build:packages
cp mobile/.env.example mobile/.env
npm run dev:mobile # or: cd mobile && npx expo startBuild the native development client once with npm run ios -w mobile or
npm run android -w mobile (requires Xcode or Android Studio). Then use the Expo dev server
and press i or a to open the installed client.
For the iOS simulator, EXPO_PUBLIC_API_URL=http://localhost:8008 reaches Docker on the Mac.
For an Android emulator, use http://10.0.2.2:8008 or run adb reverse tcp:8008 tcp:8008
to use localhost. On a physical phone, localhost refers to the phone itself.
People see plain messages ("Couldn't reach Orbyn…"); the details of a failed request (the call,
status, what the server said, its request id) go to the Metro console or the device log.
Development builds also show them on screen. For a test build that should too, set
EXPO_PUBLIC_DEBUG_ERRORS=true in mobile/.env before building.
- Find your computer's LAN address, for example
192.168.1.20. - Set
EXPO_PUBLIC_API_URL=http://192.168.1.20:8008inmobile/.env. - Publish the API on all interfaces: set
API_BIND=0.0.0.0in the root.envand add the LAN origin toCORS_ORIGINSif you also serve the web app from it, thendocker compose up -d. - Restart
npx expo startso the new env is bundled, and open the project in your development build.
Push reminders need a real Expo push token, which requires:
- an EAS project id in
EXPO_PUBLIC_EAS_PROJECT_ID(create one withnpx eas-cli init), and - a development build or store build with APNs / FCM credentials configured in EAS. Use a physical device to verify actual delivery; Expo Go is not the push validation path.
Flow inside the app:
- In Settings, tap Enable mobile notifications. The app asks for permission and calls
Notifications.getExpoPushTokenAsync. - It registers the token with
POST /devicesand caches it in secure storage. - The backend worker sends reminders to Expo's push API and stores the ticket, then checks the
receipt after 15 minutes. Tokens reported as
DeviceNotRegisteredare removed automatically. - On logout the app calls
DELETE /devicesso the phone stops receiving reminders for that account.
If your Expo account has "enhanced push security" enabled, set EXPO_ACCESS_TOKEN in the root
.env so the worker can authenticate.
One listener in the app (mobile/src/hooks/useAppLinks.ts, read by parseAppLink in
@orbyn/core) opens every link into the app: orbyn://add (a new task; with ?text= the words
fill Today's quick add for you to check and add — a link never adds anything by itself),
orbyn://agenda, orbyn://scan, orbyn://assistant, orbyn://share?url=&text= (Save to Orbyn),
orbyn://today, orbyn://review[/<id>] (the Inbox until the Review inbox arrives),
orbyn://search?q= (Search, with the words typed), and orbyn://doc/<id>, orbyn://task/<id> and
orbyn://project/<id> — or the web app's /app/… paths for the same. A link that arrives signed
out opens after sign-in.
Universal links and app links. mobile/app.config.js adds associatedDomains
(applinks:<host>) on iOS and a verified https://<host>/app/ intent filter on Android to what
app.json holds, without changing app.json. The host is EXPO_PUBLIC_WEB_URL's (https only),
else orbyn.dev. The web host serves the matching /.well-known/apple-app-site-association and
assetlinks.json once the API has APPLE_TEAM_ID and ANDROID_CERT_FINGERPRINTS (see
docs/api.md). This takes effect in a new native build; the Associated Domains capability must be on
for the app id.
Copy link is in the ⋯ of a page, task and project (expo-clipboard; a build made before it was
added falls back to the share sheet). Search (Workspace → Search) finds pages, tasks and
projects with the web's filters and operators (tag:, project:, team: — team:personal for
what's in no team — is:, edited:) and a one-line summary; with nothing typed it lists what you opened last on any device. Settings →
Privacy → Security and data shows the dated page the web has at /security.
Long-pressing the app icon offers New task, Today's agenda, Scan notes and Ask assistant, each
opening one of those links. They come from a config plugin and a small local module,
mobile/modules/orbyn-quick-actions (listed in app.json → plugins):
- iOS:
UIApplicationShortcutItemsin Info.plist, each carrying its link; the module's app delegate subscriber hands the chosen one to JS (takeInitialQuickAction,onQuickAction). - Android: static app shortcuts (
res/xml/orbyn_shortcuts.xml) that open the link on the main activity, so it arrives throughLinking.
This needs a new native build (EAS or expo prebuild) — Expo Go and the web build have no
quick actions; the same is true of receiving shared text and links (the share extension's
supportsText/supportsWebUrlWithMaxCount on iOS and text/plain on Android, in app.json). The
JS side, the plugin's Info.plist and manifest output (npx expo config --type introspect) and the
link parser are checked; the icon menu itself is verified on a device after that build.
The app registers the orbyn:// URL scheme, and opening orbyn://add?text=<your task> opens Today
with the text in quick add, parsed as you'd typed it; one tap on + adds it (via POST /items/quick).
No native extension is needed:
- In the Shortcuts app, add an Open URL action with
orbyn://add?text=Buy%20milk(or use an Ask for Input / dictation step and put its result in thetextquery value). - Name the Shortcut (e.g. "Add to Orbyn") and, on iOS, add it to Siri — then say it to capture a task hands-free.
A link opened while signed out waits for sign-in. On-device behaviour is verified by hand; the
link parsers (parseAddDeepLink, parseAppLink in @orbyn/core) are unit-tested. Home-screen widgets and an Apple
Watch app are separate native targets — see the next section.
The app ships the data side of a home-screen widget and an Apple Watch app, plus the native target
sources under mobile/targets/. The Swift is built in Xcode — it is not compiled by CI (CI only
type-checks the JS and bundles with Metro), so treat it as reviewed-but-unverified until you build it
on a Mac.
How it works:
@orbyn/corebuildGlance()turns the person's items into a compact "glance" (open/done tasks today, overdue count, next event). It is unit-tested.mobile/src/lib/widget.tswrites that glance to the shared App Group container after every refresh and callsExtensionStorage.reloadWidget()(@bacons/apple-targets). It no-ops off iOS and in Expo Go, so it is always safe to call.mobile/targets/widget/is a WidgetKit widget (small + medium) that reads the glance from the App Group.mobile/targets/watch/is a SwiftUI Watch app that receives the glance over WatchConnectivity and caches it.mobile/modules/orbyn-watch/is a local Expo native module (the phone side) that sends the glance to the Watch over WatchConnectivity.
The Swift type-checks against the SDKs shipped with Xcode (verified with Xcode 26 / Swift 6):
xcrun --sdk iphoneos swiftc -typecheck -parse-as-library -target arm64-apple-ios17.0 \
targets/widget/index.swift
xcrun --sdk watchos swiftc -typecheck -parse-as-library -target arm64_32-apple-watchos10.0 \
targets/watch/index.swiftThe orbyn-watch module's WatchConnectivity logic type-checks against the iOS SDK too; its Expo
Module wrapper needs the Expo pods, so it is checked as part of a full build. Still to do on a Mac:
the full app build (pods + link + signing) and running the widget/Watch on a device or simulator.
To build it, add these to app.json (kept out of git because it also holds your EAS project id):
Then generate and build the native project:
cd mobile
npx expo prebuild -p ios --clean
xed ios # select the OrbynWidget / OrbynWatch scheme and runPhone → Watch sync is wired: the local Expo module mobile/modules/orbyn-watch/ sends the glance
to the Watch with WCSession.updateApplicationContext (watchOS can't read the phone's App Group),
and mobile/src/lib/widget.ts calls it after each refresh. The JS side uses
requireOptionalNativeModule, so it no-ops in Expo Go and CI and activates in a dev/prod build. The
widget needs no extra wiring once the App Group is set. Everything native (widget, Watch app, and
this module's Swift) is compiled and verified in Xcode, not by CI.
All of this is written and type-checked (xcrun swiftc -typecheck for the Swift), and needs the
owner's native build (EAS) to run; Expo Go and the web build have none of it, and the JS no-ops
there. mobile/app.config.js adds everything without touching app.json:
- Widgets you tick tasks from (
mobile/targets/widget/): the Home Screen widget (small, medium, large) set to Today, Up next, or one list or project, with a circle to tick each task and New task; the Lock Screen's Next up (rectangular, inline, circular). They read the glance the app keeps in the App Group (buildGlancein@orbyn/core, now with the tasks to tick). - Control Center and Lock Screen capture (iOS 18): the Add to Orbyn control opens quick
add. The focus session shows as a Live Activity on the Lock Screen and in the Dynamic Island
(
mobile/modules/orbyn-capture, started and ended fromuseFocusSession). - Siri, Shortcuts and Spotlight (
modules/orbyn-capture/native/ios/OrbynIntents.swift, put in the app target by the module's config plugin): Add to Orbyn, Add to today's agenda, What's next and Start focus. They run without opening the app (Start focus opens it). - Android: the Home Screen widget (
OrbynTodayWidget, tick tasks and New task) and the Quick Settings tile "Add to Orbyn" (OrbynCaptureTile), written into the app's package by the same plugin, with their layouts and the palette's own colours (light and dark). - App icon (DSN-06): light, dark and tinted on iOS 18, and adaptive plus themed (monochrome)
on Android, all from
mobile/assets/icons/in the palette's own green.
The native side never holds your sign-in. A tick or a capture made in a widget, a control, Siri or
the tile waits in shared storage (pending, read by readPending in @orbyn/core) and the app
sends it the next time it comes to the front (flushPending in mobile/src/lib/widget.ts); the
widget shows the change at once.
The owner's steps: set ios.appleTeamId and the App Group (group.com.orbyn.planner) in the local
app.json, turn on the App Groups and Siri capabilities for com.orbyn.planner and the widget's
bundle id in the Apple Developer account, then eas build. Check on a device: ticking in the
widget, the Lock Screen widget, the Control Center control, "Hey Siri, add to Orbyn", the focus
Live Activity, the Android widget and tile, and the icon in dark and tinted modes.
cd mobile
npx eas-cli login
npx eas-cli init # writes the project id into app.json
npx eas-cli build --profile preview # internal distribution build
npx eas-cli build --profile production
npx eas-cli submit --profile productionProfiles are defined in eas.json. Bundle identifiers are com.orbyn.planner on both platforms;
change them in app.json before publishing under your own organization.
npm run typecheck -w mobileNative signing and App Store / Play Store submission require your developer accounts. JavaScript bundle checks do not prove native compilation, signing, or device push delivery.
{ "expo": { "ios": { "appleTeamId": "YOURTEAMID", "entitlements": { "com.apple.security.application-groups": ["group.com.orbyn.planner"], }, }, "plugins": [ "expo-secure-store", "expo-notifications", "@react-native-community/datetimepicker", "@bacons/apple-targets", ], }, }