RN Chatbot Camera
One React Native codebase, three shippable targets — iOS, Android, and an installable PWA — built as a mockup for a future chat-API-backed app
What started as a one-screen chat + camera sample to learn the Android APK sideload pipeline grew into a full exploration of cross-platform publishing from a single RN codebase. This page captures the architecture, the build workflow for all three targets, the strategies for testing on real hardware we didn't own, and the pile of dependency-discipline lessons that cost us EAS build credits.
Goal
Three questions end-to-end:
- How do you structure one React Native codebase so the same code ships to iOS, Android, and the web?
- How do you avoid burning paid build credits on trivial mistakes — missing peer deps, bad resource alignment, fonts that don't load?
- When you don't own the target device (a specific iPhone model, a Pixel tablet, an old ChromeOS box), how do you test on real hardware remotely?
Architecture: one codebase, three outputs
Expo's managed workflow wraps the React Native renderer for native targets and adds react-native-web for the browser. The app code never branches on platform except where a feature is genuinely unavailable — camera on web is the only gate we needed.
┌──── your JS / TSX code (App.tsx + src/) ────┐
│ │
▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐
│ iOS Sim │ │ iOS Dev │ │ Android │ │ Web / PWA │
│ (.app) │ │ (.ipa) │ │ APK/AAB │ │ (web-build/) │
└──────────┘ └──────────┘ └──────────┘ └──────────────┘
▲ ▲ ▲ ▲
│ │ │ │
eas build eas build eas build expo export
--simulator --preview --preview --platform web
Native builds run on EAS Build's cloud workers. The web build runs locally in ~5 seconds and produces a plain folder of static files you can host on GitHub Pages, S3, Cloudflare Pages, or any static host.
Android
- EAS Build cloud → signed APK
- Sideload via
adb install previewprofile = APK for direct installproduction= AAB for Play Store
iOS
simulatorprofile = free, .app for Simulatorpreview= ad-hoc IPA (Apple Developer account)- Up to 100 registered devices / year
- Distribute via TestFlight or Configurator
Web / PWA
expo export --platform web→ static site- No cloud builds, no credits
- Installable via
manifest.json - Camera tab gated via
Platform.OS
What's in the codebase
The shell is a 4-tab bottom-nav app with a light theme, mirroring the layout of a real biology-classroom companion app this is mocking up.
- Home — welcome card with three circular icon buttons, collapses into a chat conversation once the user sends a message. Input bar is persistent.
- Capture — step pills (Capture ▸ Adjust ▸ Report), a green-bordered tip card, and Take Photo / Upload buttons. The camera itself is an
expo-camerafull-screen overlay. - Survey — two external-link step cards that open pre- and post-experiment surveys in the browser.
- About — static info about the stack.
A shared <OfflineBanner /> at the top of the app subscribes to @react-native-community/netinfo and shows an orange bar whenever the device reports no internet. Works identically on iOS (SCNetworkReachability), Android (ConnectivityManager), and web (navigator.onLine). Since the real app will depend on a chat API, this is the right place to tell the user "no replies until connectivity is back."
// src/OfflineBanner.tsx — one component, three platforms
useEffect(() => {
const unsub = NetInfo.addEventListener((state) => {
const reachable = state.isInternetReachable === null
? !!state.isConnected
: !!state.isInternetReachable;
setOnline(reachable);
});
return () => unsub();
}, []);
if (online) return null;
return <View style={styles.root}>You're offline — chat replies aren't available…</View>;
Pre-flight validation — stop wasting build credits
EAS Build is slow (10–20 minutes on the free tier) and metered. Almost every failed build we had was catchable locally in seconds. The npm run verify script chains two checks that together reproduce everything EAS does up to the native compile step:
"verify": "expo-doctor && expo export --platform android --output-dir /tmp/expo-verify-a && rm -rf /tmp/expo-verify-a",
"verify:web": "expo export --platform web --output-dir /tmp/expo-verify-w && rm -rf /tmp/expo-verify-w",
"verify:all": "npm run verify && npm run verify:web"
expo-doctor
Validates 17 things: SDK version match on every expo-* dep, config plugin sanity, no duplicate deps, app.json consistency. Catches "I added a package with npm install instead of expo install" mismatches.
expo export
Actually runs the Metro bundler — the exact step EAS executes remotely. Any unresolved import fails here too. This is the check that catches missing peer deps like expo-font or @expo/metro-runtime.
Lessons from burned build credits
Every one of these shipped to EAS before we caught it. All would have been caught by npm run verify. Listed in order we hit them.
1. Missing expo-asset
First cloud build failed with:
Error: The required package `expo-asset` cannot be found
at getAssetPlugins (@expo/metro-config/.../ExpoMetroConfig.ts:65:11)
expo-asset is pulled in transitively by expo in most setups, but once any code path references it directly (as @expo/metro-config does), it needs to be in package.json explicitly. Fix: expo install expo-asset.
2. Missing expo-font
After adding @expo/vector-icons directly:
Unable to resolve module expo-font from
node_modules/@expo/vector-icons/build/createIconSet.js:
expo-font could not be found within the project.
@expo/vector-icons/createIconSet.js does import * as Font from 'expo-font'. If vector-icons is only reachable via the expo umbrella it works; the moment you depend on it directly, Metro also needs expo-font resolvable at the top level. Fix: expo install expo-font.
3. Invisible icons in the release APK
The build succeeded but every icon rendered as an empty glyph. Buttons still registered taps — the <Pressable> area was laid out correctly, it just had no visible content. Root cause: @expo/vector-icons loads its font async on first render. In dev that's fast enough to be invisible; in release builds on Android the first paint happens before the font resolves, and the glyph codepoint renders with no glyph. Fix: preload fonts at app boot.
// App.tsx
const [fontsLoaded] = useFonts({
...Feather.font,
...MaterialCommunityIcons.font,
});
if (!fontsLoaded) return <ActivityIndicator />;
4. takePictureAsync rejected at runtime
Capture failed:
→ Caused by: Module 'expo.modules.interfaces.filesystem.AppDirectories' not found.
Are you sure all modules are linked correctly?
expo-camera doesn't return the image in memory — it writes the JPEG to a temp file via expo-file-system's AppDirectories and returns the URI. Without expo-file-system in package.json, Expo's autolinking skips registering it natively. Fix: expo install expo-file-system.
expo dependencies work fine until something native calls into them. Metro's JS resolution and Expo's native autolinking are two different graphs — both need to know about the package. Any expo-* your code depends on, even indirectly via another library, belongs in package.json. When in doubt, use npx expo install <pkg> — it pins the version matched to your SDK and avoids the class of bugs above.
5. Android 16 KB page size compatibility
On a Pixel 10 emulator with the ps16k system image we got:
Android App Compatibility
This app isn't 16 KB compatible. ELF alignment check failed.
This app will be run using page size compatible mode.
The following libraries are not 16 KB aligned:
- lib/arm64-v8a/libexpo-modules-core.so
- lib/arm64-v8a/libhermes.so
- lib/arm64-v8a/libreact_codegen_safeareacontext.so
...
Android 15+ defaults to 16 KB memory pages. Expo SDK 52's prebuilt native libs are still 4 KB-aligned. The OS falls back to compatibility mode and shows this once. Informational, not blocking — Play Store submissions tighten on this in late 2025. Real fix: upgrade to Expo SDK 53+. Demo fix: dismiss and move on.
6. Disk space killed the emulator before any of this
Before we could even reproduce a bug on the emulator, it wouldn't launch. The AVD needed 7.2 GB free to initialize its userdata partition; the Mac had 6.3 GB free. We reclaimed 24 GB by nuking four regenerable Apple caches — nothing irreversible:
rm -rf ~/Library/Developer/Xcode/iOS\ DeviceSupport # 11 GB
rm -rf ~/Library/Developer/Xcode/watchOS\ DeviceSupport # 5.2 GB
rm -rf ~/Library/Developer/Xcode/DerivedData # 2.6 GB
rm -rf ~/Library/Caches/ms-playwright # 8.1 GB
Device-support files re-download when you plug in a device. DerivedData rebuilds on your next Xcode build. Playwright binaries re-install with npx playwright install. None affect active iOS development in progress.
Testing on real hardware we don't own
An emulator on a MacBook will not catch device-specific issues — different GPU drivers, OEM quirks, notches, specific camera hardware, low-RAM Android Go variants. Two services let us interact with real devices remotely; each has a sweet spot.
Firebase — Device Streaming
Built into Android Studio. Streams a real Pixel or Samsung device from Google's lab directly into your IDE.
- Launched mid-2024 under the "Device Streaming, powered by Firebase" brand.
- Free tier: a monthly allotment of minutes per Google Cloud project.
- Run your installed APK, interact live, see the screen, use the camera of the real device.
- Particularly good for reproducing Pixel-specific issues and testing on the newest Android builds before you own the hardware.
- Android only — iOS not supported.
When to use: Android-only testing, Pixel/Samsung device issues, checking a specific Android version you don't have locally.
BrowserStack — App Live
Paid service (monthly SaaS). Huge catalog of real iOS and Android devices in a browser.
- Hundreds of real devices — current iPhones, older iPhones, Samsung, Pixel, OnePlus, Xiaomi, across many OS versions.
- iOS and Android coverage — the only option we've found that handles both with one account.
- Upload your IPA / APK, pick a device, interact live in the browser.
- Integrates with CI — Appium, Espresso, XCUITest, and so on, if you want automated end-to-end runs on a device matrix.
- Cost is the gate: ~$40/month for live-interactive access, more for automation; no meaningful free tier.
When to use: iOS device testing (no Firebase option here), multi-OEM Android coverage, pre-release sweep across a matrix of device/OS combos.
- Day-to-day: iOS Simulator + Android Emulator locally (free).
- Pixel-specific bugs or "does it work on Android 15?": Firebase Device Streaming (free allotment).
- Pre-release regression on a device matrix + any real iPhone testing: BrowserStack (paid, but the only iOS option).
Permissions across platforms
Each platform handles the camera permission differently — and the app config has to speak all three dialects:
// app.json — one config, three platforms
{
"expo": {
"ios": {
"bundleIdentifier": "com.example.rnchatbotcamera",
"infoPlist": { "ITSAppUsesNonExemptEncryption": false }
},
"android": {
"package": "com.example.rnchatbotcamera",
"permissions": [
"android.permission.CAMERA",
"android.permission.RECORD_AUDIO"
]
},
"web": {
"bundler": "metro",
"name": "Study Companion",
"shortName": "Companion",
"themeColor": "#2563eb",
"display": "standalone",
"lang": "en"
},
"plugins": [
["expo-camera", {
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera to capture photos in chat."
}]
]
}
}
The expo-camera config plugin generates the iOS NSCameraUsageDescription key and appends the Android manifest permission automatically — we don't have to maintain two separate consent strings.
Build commands (the cheat sheet)
# Sanity check before any EAS build
npm run verify:all
# Android APK, sideloadable
eas build --profile preview --platform android
# iOS Simulator build — free, no Apple Dev account needed
eas build --profile simulator --platform ios
# iOS device build — ad-hoc IPA, requires Apple Dev account
eas build --profile preview --platform ios
# Web / PWA — builds locally, no cloud credits
npm run build:web
npx serve web-build # local preview
Installing the APK on a Chromebook
The original goal — still works. ChromeOS runs Android apps inside a sandboxed Android subsystem (ARC). Sideloading goes through ADB at a fixed IP:
sudo apt update && sudo apt install -y android-tools-adb
adb connect 100.115.92.2:5555 # the magic ARC IP
adb install rn-chatbot-camera.apk
Takeaways for the real app
- One codebase for three platforms is real, with one honest gate (camera on web). Offline detection, layout, state, icons, navigation — all identical across iOS, Android, and PWA.
- Dependency discipline is the dominant cost driver on EAS's metered tier. Every single wasted build we had was a missing peer dep.
npm run verifywould have caught them in seconds. expo install, notnpm install, for anything that ships native code. It version-pins to your SDK and saves future-you from mystery build failures.- Real-device testing is solvable without owning the devices. Firebase Device Streaming handles Android for free within limits; BrowserStack handles iOS and broader OEM coverage when it's time to pay for confidence.
- The chat API layer is the next frontier. The offline banner wiring, the network-aware UI, and the three-platform shell are ready. Plugging in a real streaming chat endpoint and wiring the send button to it is a weekend's work from here.
Repository layout
projects/rn-chatbot-camera/
├── index.html # this page
└── app/
├── App.tsx # Root: font preload, tab state, offline banner
├── app.json # Expo config: iOS + Android + web blocks
├── eas.json # EAS profiles (dev / simulator / preview / production)
├── package.json # deps pinned to SDK 52; verify scripts
├── package-lock.json # committed for reproducible builds
├── tsconfig.json
├── babel.config.js
├── index.js # registerRootComponent(App)
├── README.md # three-platform workflow doc (for downstream dev)
└── src/
├── theme.ts
├── Header.tsx
├── TabBar.tsx
├── OfflineBanner.tsx # NetInfo subscriber, cross-platform
├── HomeScreen.tsx
├── CaptureScreen.tsx # camera flow, web-gated
├── CameraScreen.tsx # expo-camera full-screen view
├── SurveyScreen.tsx
├── InfoScreen.tsx
├── bot.ts # local rule-based chat stub
└── types.ts