# 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.
Sign-up, first-pet onboarding, a light/dark/auto theme, and German/English
i18n are also done — see their own sections 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.tsx` redirects 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-expo` is
deprecated, don't reinstall it). Session JWT persists in the OS keystore
via `expo-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 ``/`` web components, which
don't render in React Native. Sign-up has no invite-code step — unlike
web's `/join` gate, `INVITE_REQUIRED` is globally off on the backend
since launch.
- **API:** `src/api/trpc.ts` creates a `@trpc/client` pointed at
`${EXPO_PUBLIC_API_URL}/api/trpc`, with the Clerk session token attached
as `Authorization: Bearer ` on every request (`useAuth().getToken`,
called per-request so a refreshed token is always used).
- **Type safety:** `AppRouter` is 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 because `mobile/` is
nested inside the same repo: Node's (and TypeScript's) module resolution
walks up to the root `node_modules` for `@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.ts`
centralizes the `inferRouterOutputs` types every screen uses
(`Pet`, `FeedPost`, etc.) instead of repeating the long relative import.
- **Design system:** `src/theme/tokens.ts` mirrors the web app's
`DESIGN.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 explicit `petId`
and asserts ownership server-side — there's no server-side "current pet".
`src/context/ActivePetContext.tsx` is mobile's equivalent of web's
`ActivePetContext` (MMKV-backed instead of `localStorage`), 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 via `PersistQueryClientProvider` in
`app/_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 through `expo-image`
for the same reason (disk-cached, unlike RN's built-in `Image`).
- **Navigation:** `expo-router`'s built-in ``
(`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 pushing `app/(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:
1. **CORS doesn't apply.** CORS is a browser same-origin mechanism; a native
app's `fetch` isn't a browser context, so the "just needs to reach it"
requirement was already satisfied by the API being reachable over HTTPS
at `pawfeed.org`.
2. **Clerk already accepts bearer tokens.** `src/proxy.ts` already marks
`/api/trpc/(.*)` as a public route at the middleware level (tRPC does its
own per-procedure auth — see the comment there), and `auth()` in
`src/trpc/init.ts` recognizes both the browser's session cookie AND an
`Authorization: Bearer` JWT 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.
```bash
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="" 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
onboarding/
_layout.tsx # Stack with a close-to-/(app) button, shared by all onboarding steps
pet.tsx # 5-step flow: guidelines → upload consent → spider opt-in → pet form → avatar
src/
api/trpc.ts # tRPC client, AppRouter type import, query client factory
api/types.ts # inferRouterOutputs shortcuts (Pet, FeedPost, ...)
context/ActivePetContext.tsx # MMKV-backed active-pet state
context/ThemeContext.tsx # light/dark/auto preference (owner.getThemePreference, see "Dark mode")
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 → i18n key/icon map, see "Internationalization"
theme/tokens.ts # design tokens (light `colors` + dark `darkColors`), see "Dark mode"
i18n/index.ts # i18next setup, device-locale detection, language preference, see "Internationalization"
i18n/locales/{en,de}.json # translation catalogs (kept in exact key-parity, see "Internationalization")
components/ # Button, TextField, Card, PostCard, PawButton, PetAvatar,
# PetIdentityBadges, FollowButton, StoryTray, PawRing, VideoPlayer,
# OptionPicker (species/breed dropdown, shared with Explore + onboarding)
components/health/ # HealthConsentGate, WeightLogSection, VetVisitSection, VaccineSection
components/onboarding/ # OnboardingGuidelines, UploadConsent, ArachnophobiaOptIn, PetCreateForm, AvatarStep
```
## 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`'s `android.package` (`org.pawfeed.app`) is set now;
`ios.bundleIdentifier` is 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`) is the path used for producing signed Android
binaries — no local Android Studio toolchain needed. The app is live on
Google Play (closed testing, since 2026-09-08); iOS is still deferred.
Build a new production `.aab`:
```bash
npx eas-cli build --platform android --profile production
```
Bump `app.json`'s `version` and `android.versionCode` first — Play
Console rejects a re-upload with an unchanged `versionCode`.
### Shipping a fix without a new Play Store review (EAS Update)
`expo-updates` is configured (`runtimeVersion` policy `"appVersion"`,
per-profile channels in `eas.json`) — see Gitea #35. A **pure JS/UI
fix** (no new native dependency, no `app.json` native-config change: no
new permission, icon, plugin, etc.) can go out as an over-the-air update
instead of a new store build/review:
```bash
npx eas-cli update --branch production --message "short description"
```
Installed apps built from the `production` profile check for and apply
this automatically on next launch (`expo-updates`'s default `ON_LOAD`
behavior — no extra code needed). The update's `runtimeVersion` must
match the installed build's `version` string exactly, so this only
works between builds that share the same `app.json` `version` — bumping
`version` for a native-code build starts a new runtime-version lineage
that old OTA updates won't reach and that needs its own store review.
## 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:
- Web's Explore "Trending" **hashtags** — mobile has no hashtag search/browse
UI at all. The trending **posts** half of that tab isn't a separate mobile
screen either, but its data (`explore.getTrending`) is now surfaced by
mixing a few of those posts into the Feed itself at semi-random intervals
(`src/lib/interleave-trending.ts`, marked with a small "Trending" badge on
`PostCard`) rather than building a second tab/grid — see the Feed section
of "Folder structure" above for where that lives.
Onboarding (`/onboarding/pet` — guidelines → upload consent →
Arachnophobie-Helfer opt-in → pet form → avatar), sign-up, dark mode, and
i18n are now built — see their own sections below.
### Onboarding and pet management
`app/onboarding/pet.tsx` ports web's five-step onboarding
(`src/app/onboarding/pet/page.tsx`) exactly: community guidelines
(`legal.getPendingUpdate`/`acknowledge`, auto-skips if nothing's been
published) → upload-rights + data-processing consent
(`legal.getUploadConsentStatus`/`acknowledgeUploadConsent`, auto-skips if
already given) → Arachnophobie-Helfer opt-in (`owner.setHideSpidersPreference`,
never blocks) → pet form (name/nickname/species/breed/bio/adoption story,
`src/components/onboarding/PetCreateForm.tsx`) → avatar photo
(`src/components/onboarding/AvatarStep.tsx`, skippable). Web reuses this
same route for "add another pet" from an existing account (not just
first-time onboarding) — mobile does too, via the Pets tab's `+` header
button (`app/(app)/(tabs)/pets.tsx`).
- **`OnboardingRedirect`** (`app/_layout.tsx`) sends any signed-in owner
with zero pets to `/onboarding/pet`, mirroring web's
`(app)/layout.tsx` redirect. It gates on `useActivePet()`'s `isPending`
(not `isLoading`) — `isLoading` is `false` while the pets query is
still `enabled: false` (before Clerk confirms `isSignedIn`), which made
`pets` default to `[]` and look like "no pets" long enough to fire a
`router.replace` loop hard enough to hit React's "Maximum update depth
exceeded" on cold start. `isPending` stays `true` through that whole
window instead.
- **Species/breed pickers** (`src/components/OptionPicker.tsx`, shared
with Explore's breed filter) are a plain `ScrollView` + `.map()`, not a
`FlatList` — they sit inside the onboarding screen's own `ScrollView`,
and nesting a `FlatList` (a `VirtualizedList`) inside a `ScrollView` with
the same orientation breaks touch handling badly enough that list items
stopped responding to taps (RN's own "VirtualizedLists should never be
nested..." warning undersells how broken this gets in practice).
- **Delete a pet** lives on the edit screen
(`app/(app)/pets/[petId]/edit.tsx`, `pets.delete` + a confirm `Alert`) —
if the deleted pet was the active one, `ActivePetContext`'s new
`clearActivePetId()` resets it so the "auto-pick pets[0]" effect (or
`OnboardingRedirect`, if none remain) takes over.
### Dark mode
`src/context/ThemeContext.tsx` provides a light/dark/auto preference,
backed by the same `owner.getThemePreference`/`setThemePreference`
procedures the web app uses (Gitea #8) — `null` means "follow system",
resolved via React Native's `useColorScheme()`. The toggle lives at the
bottom of the Pets tab (no dedicated settings screen exists yet) as three
buttons — Hell/Dunkel/Automatisch — and switches instantly, no reload.
**Only screens/components that call `useTheme()` are actually dark-mode
aware; most of the app is still light-only.** This isn't an oversight to
fix later in five minutes — it's a real constraint of how React Native
styles work: `StyleSheet.create({...})` at module scope runs **once**,
when the file is first loaded, and bakes in whatever color string it was
given at that moment. Changing `colors.porchWhite` afterwards doesn't
touch the frozen style objects already handed to already-mounted
components — there's no live binding, unlike a CSS custom property on
web. Making a screen theme-aware means converting its module-level
`const styles = StyleSheet.create({...})` into a `function
makeStyles(colors: ColorTokens) { return StyleSheet.create({...}) }`
called via `useMemo(() => makeStyles(colors), [colors])` inside the
component, with `colors` coming from `useTheme()` instead of a static
import from `theme/tokens.ts`. `theme/tokens.ts`'s top comment and
`ThemeContext.tsx`'s own comment cover this in more depth.
**Converted so far:** the shared primitives (`Button`, `TextField`,
`Card`, `PetAvatar`, `OptionPicker`), `PostCard`, `StoryTray`, the Feed
tab, the Explore tab, the Notifications tab, the Pets tab, the full pet
profile (view/edit/followers/following), the whole health area (consent
gate + weight/vet/vaccine sections), and all navigation chrome (root
`_layout.tsx`'s status bar, `(app)/_layout.tsx` and `onboarding/_layout.tsx`'s
Stack headers — including `headerTintColor`, without which the back
button's default tint stayed invisible against a dark header — and
`(tabs)/_layout.tsx`'s tab bar and headers) — Stack/Tabs `options` objects
are plain props recomputed on every render, so those didn't need the
`makeStyles` dance, just reading `colors` from `useTheme()` directly.
**Not yet converted:** Search, Messages, comments, stories, milestones,
sign-in/sign-up, and the post/story/milestone creation screens, plus a
few shared components whose icon colors don't fully adapt (`PawButton`,
`PawBackButton`, `PetIdentityBadges`, `RepostCard`, `VideoPlayer`). They'll
render correctly in light mode regardless of the device/account theme
setting until someone applies the same `makeStyles` conversion to them.
### Internationalization (i18n)
German and English, matching web's supported locale set exactly
(`src/i18n/request.ts`'s `LOCALES`) — mobile intentionally didn't scope
this to the wider EN/ES/IT/FR list mentioned in older planning notes, since
web itself only ships EN/DE today. `i18next` + `react-i18next` (not
`next-intl`, which is Next.js-specific) with one flat "translation"
namespace per locale (`src/i18n/locales/{en,de}.json`), addressed by
dotted keys (`t("petForm.createButton")`) — mirrors next-intl's own
per-namespace-object shape closely enough that terminology was copied
verbatim from `../messages/{en,de}.json` wherever the same concept already
existed there (e.g. `MilestoneTypes`, `PetForm`).
- **Preference resolution** (`src/i18n/index.ts`) mirrors web's own
cookie-only approach: `getLanguagePreference()`/`setLanguagePreference()`
persist a `"de" | "en" | null` override to MMKV (`null` = follow device
language, via `expo-localization`'s `getLocales()`) — there's no backend
field for it, same as web's locale living only in the `NEXT_LOCALE`
cookie, never on the `Owner` row. The switcher lives right below the
Darstellung (theme) section on the Pets tab, same three-button pattern
(Deutsch/English/Automatisch), and calls `queryClient.invalidateQueries()`
on change so already-cached species/breed names and dates refetch under
the new language instead of staying stale.
- **`react-i18next`'s `useTranslation()` needs no theme-style `makeStyles`
workaround** — unlike colors, text updates on every render automatically
once `i18n.changeLanguage()` fires, so switching language re-labels the
entire app instantly with no per-screen conversion debt (contrast with
"Dark mode" above, which very much has that debt).
- **Backend locale resolution for mobile** (`src/trpc/init.ts`, root
`CLAUDE.md`'s `createTRPCContext`): procedures that resolve
DB-stored translatable text (Species/Breed names, via `ctx.locale`) read
a `NEXT_LOCALE` cookie that a React Native `fetch`/`httpBatchLink`
request can't set. Mobile's tRPC client (`src/api/trpc.ts`) sends an
`x-locale: ` header instead; the backend checks the
cookie first (web unaffected), then this header (mobile-only fallback).
`pets.listSpecies`/`listBreeds` take `locale` as an explicit input rather
than reading `ctx.locale` — those call sites (`explore.tsx`,
`PetCreateForm.tsx`) pass `i18n.language` directly instead of a
hardcoded `"de"`, which was the actual state before this session (a
latent bug: species/breed *pickers* were German-only while the *rest* of
a pet's data was already resolving to English via `ctx.locale`'s
default, since mobile requests never carried a cookie at all).
- **Needs a NAS deploy**, unlike most `mobile/`-only work — the
`x-locale` header fallback is a shared backend file
(`src/trpc/init.ts`), and mobile's live default `EXPO_PUBLIC_API_URL`
points at the deployed site, not local dev (see
[[project_mobile_deployment_target]]).
- **`expo-localization` requires a native rebuild** (`expo prebuild` /
`expo run:android`) to actually link — it was added as a new native
module via config plugin, the same category of change as the FCM
credentials step in "Push notifications" below. Until that rebuild
happens on a given device, `Localization.getLocales()` throws `Cannot
find native module 'ExpoLocalization'`; `deviceLocale()` in
`src/i18n/index.ts` catches that and falls back to `defaultLocale`
("en") rather than crashing the app at module-load time — "Automatisch"
silently behaves like "English" on an un-rebuilt dev client instead of
actually reading the device's system language.
### 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:
1. **EAS project id** (`app.json`'s `extra.eas.projectId`) — via
`eas login` + `eas init --account pawfeed`. Without this,
`getExpoPushTokenAsync` has nothing to attach to and the hook no-ops
with `[push] No EAS project id configured...`.
2. **FCM credentials for Android** (`app.json`'s `googleServicesFile` →
`google-services.json`, gitignored — redownload from the Firebase
Console's `org.pawfeed.app` Android app on a fresh clone). This is
baked into the native project at `expo prebuild` time, 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.
3. **The FCM *service account key* uploaded to EAS** via
`eas credentials -p android` (needs `eas.json` to 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 returning
`Unable to retrieve the FCM server key for the recipient's app` even
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.
### Content moderation, health-form dates, and image zoom
Three smaller found.md fixes worth documenting since they're easy to
regress:
- **Reporting** (`src/components/ReportSheet.tsx`) — a `MoreVertical`
button on `PostCard`'s header opens a bottom sheet with the same 5
`ReportReason` values as web's `ReportSheet.tsx`, calling the existing
`reports.create` (no backend change needed). Trimmed versus web: tapping
a reason submits immediately, there's no separate optional notes
textarea yet even though the backend already accepts one.
- **Health form dates** (`src/lib/date-format.ts`) — the weight/vet-visit/
vaccine forms take `DD-MM-YYYY` and convert to the backend's
`YYYY-MM-DD` right before the mutation call. The actual found.md
"Gesundheitsdaten werden nicht gespeichert" bug wasn't persistence at
all — `addWeightLog`/`addVetVisit`/`addVaccine` had no `onError`
handler, so a validation failure (wrong date format) just silently did
nothing. All three now show an alert on failure.
- **Pinch-to-zoom** (`app/(app)/image-viewer.tsx`) — a plain `PanResponder`
tracking two-finger distance, driving an `Animated.Value` scale on the
full-size image. Deliberately not `react-native-gesture-handler` (not a
dependency yet) — this is a single scale gesture with no pan/rotate, a
raw `PanResponder` is enough.
### Pets tab: legal links, version, language flags
Below the language switcher, `LegalSection` (`app/(app)/(tabs)/pets.tsx`)
links to the same fixed-language legal pages web links to
(`/impressum`, `/datenschutz`, `/nutzungsbedingungen` — see root
`CLAUDE.md`, these are never localized) via `Linking.openURL`, plus the
installed app version read from `Constants.expoConfig?.version`. The
language switcher's three buttons use flag emoji (🇩🇪/🇬🇧/🌐) instead of an
icon — Android renders these natively, unlike the Windows-Chrome
flag-emoji-renders-as-text-codes problem web ran into (fixed there with
inline SVGs instead), which doesn't apply on a native Android renderer.