sign-in.tsx only ever covered login (per its own comment "sign-up screens are follow-up work"). Adds the other half: email/password sign-up using Clerk's Futures API (signUp.password() -> verifications.sendEmailCode()/ verifyEmailCode() -> finalize()), mirroring sign-in.tsx's two-step credentials/verify shape. No invite-code step, since INVITE_REQUIRED is globally off on the backend since launch. Verified live on the Android emulator: real Clerk validation surfaces correctly (breached-password rejection), and a valid submission correctly sends a verification email and transitions to the code-entry step. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PawFeed Mobile
Native Android/iOS client for PawFeed, built with React Native + Expo (Expo
Router). It talks to the exact same backend the web app uses — the
self-hosted Docker deployment described in ../docker/SCALING-ROADMAP.md —
over the existing tRPC API. No backend/Docker changes were needed to make
this possible, see "Why no backend changes" below.
Decided over Capacitor (see the now-superseded ../mobil.md) because a
native RN app gives better performance/feel and can still share real logic
with a future web-shared package, at the cost of more upfront work — see the
architecture-decision thread in this session's handoff.
Status: Phase 1 — real Feed/Explore/Notifications/Pets tabs against the live backend, not just a connectivity scaffold anymore. Post creation, comments, repost, pet profile detail (view/follow/edit/followers-following), stories (tray/creation/viewer), video playback, search, health tracking (consent gate + weight/vet/vaccine logs), milestones, and message sending/receiving are done and verified end-to-end against the live backend. Push notifications are fully working — see "Push notifications" below. Android is the priority platform; iOS builds need a Mac + Apple Developer account and come later (same codebase, no separate source tree — see "Android-first, then iOS" below).
Architecture
- Routing: Expo Router (file-based,
app/).(auth)and(app)are route groups;app/_layout.tsxredirects between them based on Clerk's session state (useProtectedRoute), mirroring Clerk's official expo-router integration pattern. - Auth:
@clerk/expo(the current SDK —@clerk/clerk-expois deprecated, don't reinstall it). Session JWT persists in the OS keystore viaexpo-secure-store(src/lib/tokenCache.ts) instead of Clerk's in-memory default. Sign-in and sign-up (app/(auth)/sign-in.tsx,sign-up.tsx) are both custom screens using Clerk's "Futures" API (signIn.password()/signUp.password()+verifications/finalize()) rather than the pre-built<SignIn/>/<SignUp/>web components, which don't render in React Native. Sign-up has no invite-code step — unlike web's/joingate,INVITE_REQUIREDis globally off on the backend since launch. - API:
src/api/trpc.tscreates a@trpc/clientpointed at${EXPO_PUBLIC_API_URL}/api/trpc, with the Clerk session token attached asAuthorization: Bearer <token>on every request (useAuth().getToken, called per-request so a refreshed token is always used). - Type safety:
AppRouteris imported as a type-only import directly from the web app's../src/trpc/routers/_app.ts— no code duplication, no publishing a package. This works becausemobile/is nested inside the same repo: Node's (and TypeScript's) module resolution walks up to the rootnode_modulesfor@trpc/server,zod, etc., so the type import resolves without installing those packages a second time here. If a future refactor turns this repo into an npm workspace, revisit this — it wasn't necessary for the initial scaffold.src/api/types.tscentralizes theinferRouterOutputs<AppRouter>types every screen uses (Pet,FeedPost, etc.) instead of repeating the long relative import. - Design system:
src/theme/tokens.tsmirrors the web app'sDESIGN.md("Spotlight Porch" — marigold-orange accent, porch-white canvas, Inter font, hairline rings instead of shadows).src/components/has the shared building blocks (Button,TextField,Card) plus feed-specific ports of the web equivalents (PostCard,PawButton,PetAvatar,PetIdentityBadges). - Active pet: every pet-scoped procedure (
feed.getFeed,reactions.toggle,notifications.list, ...) takes an explicitpetIdand asserts ownership server-side — there's no server-side "current pet".src/context/ActivePetContext.tsxis mobile's equivalent of web'sActivePetContext(MMKV-backed instead oflocalStorage), defaulting to the owner's first pet; the Pets tab doubles as the pet switcher. - Fast reopen: the TanStack Query cache is persisted to
react-native-mmkv(src/lib/storage.ts, wired viaPersistQueryClientProviderinapp/_layout.tsx) so the last-known feed/pets/notifications render immediately on relaunch while a background refetch reconciles them — no blank screen while waiting on the network. Images go throughexpo-imagefor the same reason (disk-cached, unlike RN's built-inImage). - Navigation:
expo-router's built-in<Tabs>(app/(app)/(tabs)/_layout.tsx) — Feed/Explore/Notifications/Pets, mirroring the web app's actual mobile bottom nav (src/components/layout/MobileNav.tsx), not an invented IA. Messages is a header icon button pushingapp/(app)/messages/index.tsx, matching web (there it's a floating corner button, not a tab).
Why no backend changes were needed
Two things had to be true for the app to reach the Docker-hosted backend as-is, and both already were:
- CORS doesn't apply. CORS is a browser same-origin mechanism; a native
app's
fetchisn't a browser context, so the "just needs to reach it" requirement was already satisfied by the API being reachable over HTTPS atpawfeed.org. - Clerk already accepts bearer tokens.
src/proxy.tsalready marks/api/trpc/(.*)as a public route at the middleware level (tRPC does its own per-procedure auth — see the comment there), andauth()insrc/trpc/init.tsrecognizes both the browser's session cookie AND anAuthorization: BearerJWT out of the box. Native/Expo apps sending a bearer token is Clerk's documented mobile auth flow, not a special case that needed enabling server-side.
Setup
Runs as a custom dev client, not Expo Go. @clerk/expo's Device Trust
flow (see below) and react-native-mmkv both need native code Expo Go
doesn't ship, so this app builds and installs its own native shell locally
— no EAS account/login needed, everything runs on-device via the Android
SDK already required for the emulator.
cd mobile
npm install
cp .env.example .env # already done locally with the live Clerk publishable key
# First time only (or after adding a native dependency / editing app.json):
npx expo prebuild --platform android
JAVA_HOME="<path to a JDK 17 install>" npx expo run:android
JAVA_HOME matters: RN 0.86's Gradle build needs JDK 17, and a JDK
that's too new (e.g. a bundled Android Studio JBR on JDK 21+) fails native
module CMake configuration with a cryptic "restricted method... System"
error. Eclipse Temurin 17 works
(winget install EclipseAdoptium.Temurin.17.JDK on Windows).
Once the native app is installed, day-to-day iteration doesn't need another
expo run:android — just npm start (→ expo start --dev-client) and
reopen the already-installed app; that command only bundles/serves JS.
Requires an Android emulator (Android Studio) or a physical device. See
https://docs.expo.dev/versions/v57.0.0/ for current Expo APIs — SDK 57
changed things vs. older Expo docs/training data, per AGENTS.md in this
folder.
Clerk dashboard: Native Applications API
Clerk's "Device Trust" feature (tied to the dashboard's Native Applications
API) makes signIn.status come back as needs_client_trust on a sign-in
from a new device — resolved via signIn.mfa.sendEmailCode() /
verifyEmailCode() in app/(auth)/sign-in.tsx, a one-time email-code check
per device, independent of any account-level MFA setting. If sign-in hangs
at "Clerk lädt…" forever with no error, or signIn.status never reaches
complete, check that the Native Applications API is enabled for this
Clerk instance.
.env
| Var | Purpose |
|---|---|
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY |
Same Clerk instance as the web app — copy from root .env.local's NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY. Safe to embed client-side (that's what "publishable" means). |
EXPO_PUBLIC_API_URL |
Backend origin. Defaults to https://pawfeed.org (the live deployment) — see the LAN-IP / Android-emulator alternatives in .env.example for local dev against npm run dev instead. |
EXPO_PUBLIC_SUPABASE_URL |
Same Supabase project as the web app — copy from root .env.local's NEXT_PUBLIC_SUPABASE_URL. Used to build public media URLs (src/lib/media-url.ts); safe client-side, it's the public storage origin, not a service key. |
Caution: the default .env points at the live production backend
and database — the same one real users are on (project_prod_launch_2026-08-10).
Reading (public stats, browsing) is harmless. Anything that writes data
while iterating on mobile features should point EXPO_PUBLIC_API_URL at a
local npm run dev instead (http://10.0.2.2:3000 from the Android
emulator, or the dev machine's LAN IP from a physical device).
Folder structure
mobile/
app/ # expo-router routes
_layout.tsx # ClerkProvider + persisted QueryClient/tRPC + ActivePetProvider + auth redirect
(auth)/
sign-in.tsx # email/password + Clerk Device Trust email-code step
sign-up.tsx # email/password + Clerk email-verification-code step, no invite code
(app)/
_layout.tsx # Stack hosting the (tabs) group + the messages/search/story/comment screens
(tabs)/
_layout.tsx # bottom Tabs: Feed/Explore/Notifications/Pets + header Search/Messages buttons
index.tsx # Feed (StoryTray header + infinite post list)
explore.tsx # pet discovery grid
notifications.tsx # notifications list
pets.tsx # owner's pets + active-pet switcher
messages/index.tsx # conversations list
messages/[conversationId].tsx # chat detail/compose, polls messages.list while focused (no realtime)
search.tsx # pets/people/hashtags search, three tabs over one debounced query
post-new.tsx # create a photo post (pick/upload → posts.create)
story-new.tsx # create a photo story (pick/upload → stories.create)
story-viewer.tsx # full-screen story playback (tap zones, progress bars, view tracking)
milestone-new.tsx # create a milestone post (type picker + optional photo → milestones.create)
pets/[petId]/health/index.tsx # weight/vet/vaccine logs, gated by HealthConsentGate
pets/[petId]/milestones.tsx # a pet's milestone posts (milestones.listByPet)
posts/[postId]/comments.tsx # read/write/delete comments for a post
image-viewer.tsx # full-screen tapped-image lightbox
src/
api/trpc.ts # tRPC client, AppRouter type import, query client factory
api/types.ts # inferRouterOutputs<AppRouter> shortcuts (Pet, FeedPost, ...)
context/ActivePetContext.tsx # MMKV-backed active-pet state
hooks/useDebounce.ts # generic debounce hook (search input)
hooks/usePushNotifications.ts # permission + Expo push token registration (owner.registerPushToken)
lib/env.ts # typed EXPO_PUBLIC_* access
lib/tokenCache.ts # Clerk session persistence via expo-secure-store
lib/storage.ts # MMKV instance + TanStack Query sync persister
lib/media-url.ts # Supabase public storage URL builder
lib/format.ts # pet display name + relative-time formatting
lib/sponsor.ts # active-sponsor check
lib/upload.ts # direct PUT of a picked image to a presigned Supabase URL
lib/milestone-meta.ts # MilestoneType → German label/icon map
theme/tokens.ts # design tokens mirroring the web app's DESIGN.md
components/ # Button, TextField, Card, PostCard, PawButton, PetAvatar,
# PetIdentityBadges, FollowButton, StoryTray, PawRing, VideoPlayer
components/health/ # HealthConsentGate, WeightLogSection, VetVisitSection, VaccineSection
Android-first, then iOS
Both platforms build from this one Expo codebase (expo run:android /
expo run:ios) — there's no separate iOS source tree to scaffold
separately. "Starting with Android" means:
app.json'sandroid.package(org.pawfeed.app) is set now;ios.bundleIdentifieris set to the same reverse-DNS id but untested.- Android needs a Google Play Console account (~25 € one-time, already
budgeted in
../mobil.md) before an internal test track can be created. - iOS needs a Mac (for
expo run:ios/ EAS builds with credentials) and an Apple Developer account (99 €/year) — deferred until Android is validated. - EAS Build (
eas.json— added, currently just default profiles) is the recommended path for producing signed Android/iOS binaries without a local Android Studio/Xcode toolchain; no EAS Build has been run yet, the file only exists so far becauseeas credentialsrequires it.
What's not built yet
Phase 1 (Feed/Explore/Notifications/Pets tabs, reactions, active-pet
switching, persisted cache for fast reopen) plus a full pet profile
(view/follow/edit/followers-following), post creation/upload, comments,
repost ("Paw-Back") creation, stories (tray/creation/viewer via
expo-video-adjacent expo-image, plus PawRing seen/unseen indicator),
video playback (expo-video against Mux HLS, polling while PROCESSING),
search (pets/people/hashtags, search.pets/byOwner/byHashtag), health
tracking (consent gate + weight/vet visit/vaccine logs — feeding info,
emergency vet, and the shareable Health Card are web-only for now),
milestones (view + create, restricted to the 5 user-initiated types), and
messaging (conversation list + chat detail/compose, polling while the
screen is focused since messages.ts has no realtime mechanism) are done
and verified end-to-end against the live backend on a real Android
emulator. Still missing, roughly in the order they'd unblock the most:
- No i18n — the whole app is hardcoded German text, matching the sign-in screen
- No onboarding/first-pet-creation flow. Web redirects an owner with
zero pets to
/onboarding/pet; mobile has no equivalent screen yet. A freshly signed-up account (see sign-up.tsx above) lands in(app)withactivePetId: nulland no way to create a pet from the app — every pet-scoped screen expects a pet to already exist. Needed before sign-up is actually useful end-to-end, not just reachable.
Push notifications
usePushNotifications (in app/_layout.tsx) requests notification
permission and registers the resulting Expo push token with the backend
(owner.registerPushToken); src/lib/notify.ts on the web side sends a
best-effort push through Expo's push API alongside every in-app
Notification it creates (follow, reaction, comment, message, ...).
Verified end-to-end on 2026-09-06: permission → real Expo push token →
registered in the DB → delivered to the Android emulator's notification
tray.
Getting here needed three separate one-time setup pieces, each easy to half-do and end up with a silent non-working state:
- EAS project id (
app.json'sextra.eas.projectId) — viaeas login+eas init --account pawfeed. Without this,getExpoPushTokenAsynchas nothing to attach to and the hook no-ops with[push] No EAS project id configured.... - FCM credentials for Android (
app.json'sgoogleServicesFile→google-services.json, gitignored — redownload from the Firebase Console'sorg.pawfeed.appAndroid app on a fresh clone). This is baked into the native project atexpo prebuildtime, so adding it needs a real rebuild (expo prebuild --platform android+expo run:android), unlike the EAS project id which the dev client picks up from a plain JS reload. - The FCM service account key uploaded to EAS via
eas credentials -p android(needseas.jsonto exist first, hence that file). The credentials manager has two separate Android push slots — "FCM Legacy" (a plain API key string, and Google shut the underlying Legacy API down in 2024) and "FCM V1" (a Google Service Account JSON key, what Firebase Console's "generate new private key" actually gives you). Uploading the service-account JSON to the wrong slot leaves EAS's send API returningUnable to retrieve the FCM server key for the recipient's appeven though a real push token exists and permission is granted — that symptom means step 3 landed in the wrong slot, not that steps 1–2 are broken.
Also note: getExpoPushTokenAsync can hang indefinitely rather than
reject if the device can't reach Google's registration backend — the hook
wraps it in a 15s timeout so a real failure surfaces instead of permanent
silence.
Deploy note: owner.registerPushToken is a web backend change.
Since the mobile app's default EXPO_PUBLIC_API_URL is the live
pawfeed.org deployment, this endpoint doesn't exist there until the web
app is redeployed too (ssh to the NAS, git pull, ./docker/rolling-deploy.sh
— see root CLAUDE.md's deployment section). This is the one case where
a "mobile" feature session still needs a web deploy — project_mobile_deployment_target's
"mobile changes need no NAS deploy" is about mobile/ code itself, not
backend endpoints mobile happens to call.