12 KiB
Native Mobile App Build Guide
FarmaFinder ships as a PWA and is wrapped for the App Store / Play Store via Capacitor 8. The web frontend in frontend/ is bundled into each native shell — there's no separate codebase for Android or iOS.
FarmaFinder/
├── frontend/ # React + Vite PWA (the actual UI)
├── backend/ # Node API
├── android/ # Capacitor-generated Android Studio project
├── ios/ # Capacitor-generated Xcode project
└── capacitor.config.json
The android/ and ios/ folders are real native projects — open them in their IDEs, change Swift / Kotlin / Gradle / xib as needed.
One-time setup
Common (any OS)
npm run install:all # root + frontend + backend deps
Android prerequisites
- JDK 21 (or 17). Install via your package manager or Adoptium.
- Android Studio (Hedgehog or later) with the Android SDK Platform 34 installed.
- Set
ANDROID_HOMEand addplatform-toolstoPATH, e.g.:export ANDROID_HOME="$HOME/Android/Sdk" export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" - Open
android/once in Android Studio so Gradle resolves the dependencies.
iOS prerequisites (macOS only)
- Xcode 15+ from the App Store. Run it once and accept the licence.
- Command Line Tools:
xcode-select --install - CocoaPods:
sudo gem install cocoapods(orbrew install cocoapods) - After cloning, run pod install inside
ios/App:cd ios/App && pod install && cd -
Day-to-day workflow
-
Build the web bundle
npm run build:webProduces
frontend/dist/, which Capacitor copies into the native projects. -
Sync into the native shells
npm run cap:sync- Copies
frontend/dist/→android/app/src/main/assets/public/andios/App/App/public/ - Re-runs
pod installif new plugins were added - Updates
capacitor.config.jsoninside each platform
- Copies
-
Open the native project in its IDE
npm run cap:open:android # Android Studio npm run cap:open:ios # Xcode (macOS only) -
Run on a device or emulator
npm run cap:run:android npm run cap:run:iosThese build the web bundle, sync, and launch on the first connected device/emulator.
After editing anything under frontend/, repeat steps 1 + 2 (or use cap:sync). Editing native files (Gradle, Swift, Kotlin, plist) is done directly inside android/ and ios/ — those edits survive cap sync.
Pointing the app at the backend
The native bundle loads index.html from inside the app (capacitor://localhost/ on iOS, https://localhost/ on Android by default), so relative /api/... paths do not reach the backend. Pick one of:
Option A — wrap the deployed PWA (simplest)
Add a server.url to capacitor.config.json so the WebView loads the production site:
{
"server": {
"url": "https://farmacias.hacecalor.net",
"cleartext": false,
"androidScheme": "https"
}
}
Run npm run cap:sync after the change. The native app is now a thin shell around the live PWA — fastest to ship, but useless without a network.
Option B — bundled web + absolute API URLs (recommended)
Keep the web assets bundled (no server.url) and rewrite frontend fetch() calls to use an absolute backend URL. The path of least resistance:
- Add a Vite env var to
frontend/.env.production:VITE_API_BASE=https://farmacias.hacecalor.net - Centralise
fetchcalls through a helper (e.g.frontend/src/utils/api.js):const BASE = import.meta.env.VITE_API_BASE || ''; export const apiUrl = (path) => `${BASE}${path.startsWith('/') ? path : '/' + path}`; - Replace
fetch('/api/...')withfetch(apiUrl('/api/...'))across the components. - On the backend, set
CORS_ORIGIN=https://farmacias.hacecalor.net,capacitor://localhost,https://localhostso the native WebView origin is allowed.
This lets the app work offline for cached UI and only hits the network for live data.
Permissions already declared
The two manifests are pre-wired for the features the PWA already uses:
android/app/src/main/AndroidManifest.xml
INTERNET— network callsACCESS_FINE_LOCATION/ACCESS_COARSE_LOCATION— "Sort by distance"POST_NOTIFICATIONS— Android 13+ push opt-in
ios/App/App/Info.plist
NSLocationWhenInUseUsageDescription— shown by the OS when geolocation is requestedNSLocationAlwaysAndWhenInUseUsageDescription— same for background-style requestsUIBackgroundModes: remote-notification— required for APNs push delivery
If you add features that need new permissions (camera, contacts, etc.), edit those files directly.
App icons & splash screens
Capacitor generated placeholder assets:
- Android:
android/app/src/main/res/mipmap-*/ic_launcher*.png - iOS:
ios/App/App/Assets.xcassets/AppIcon.appiconset/ - Splash:
ios/App/App/Assets.xcassets/Splash.imageset/and the Android theme
Replace these with real artwork before shipping. The easiest way is the official asset generator — drop a single 1024×1024 PNG (and a foreground/background for adaptive icons) into a resources/ folder at the project root and run:
npx @capacitor/assets generate --iconBackgroundColor '#0f766e' --splashBackgroundColor '#0f766e'
(install once: npm install --save-dev @capacitor/assets)
Splash colour and behaviour are configured in capacitor.config.json (plugins.SplashScreen). The runtime hook in frontend/src/utils/native.js hides the splash after React mounts.
Push notifications on native
The web PWA uses VAPID + the browser PushManager. Native iOS and Android cannot use Web Push. To deliver push from the existing backend to native shells:
- Android — set up Firebase Cloud Messaging, add
android/app/google-services.json, install@capacitor/push-notifications. The backend then sends to FCM tokens instead of (or in addition to) the existing Web Push subscriptions. - iOS — create an APNs key in the Apple Developer portal, enable the Push capability in Xcode, configure the
aps-environmententitlement, install@capacitor/push-notifications.
Until those are wired up, the bell button in the native app is a no-op — pushSupported() in frontend/src/utils/notifications.js returns false because there's no service worker in the native WebView.
Releasing
Android
- In Android Studio: Build → Generate Signed Bundle / APK → Android App Bundle.
- Create or reuse a keystore; the password unlocks the release signing config.
- Bump
versionCodeandversionNameinandroid/app/build.gradlefor each release. - Upload the produced
.aabto Google Play Console.
iOS
- In Xcode: select an Any iOS Device (arm64) destination → Product → Archive.
- From the Organizer, Distribute App → App Store Connect.
- Bump
CURRENT_PROJECT_VERSION(build) andMARKETING_VERSION(display) under the App target's General tab. - Submit via App Store Connect.
Both stores require: privacy policy URL, screenshots (multiple device sizes), an app description, and an age rating questionnaire. The pharmacy domain typically triggers an extra medical-content review on iOS — keep marketing copy factual.
CI/CD: GitHub Actions (iOS)
The project includes a GitHub Actions workflow for automated iOS builds, replacing the previous Bitrise pipeline.
Workflow file
.github/workflows/ios-build.yml — builds the iOS app on every ios-v* tag push, or manually via workflow_dispatch.
Trigger types
| Trigger | Behaviour |
|---|---|
git push ios-v* tag |
Builds, archives, exports IPA, uploads to TestFlight |
workflow_dispatch (manual) |
Builds and optionally uploads to TestFlight (configurable) |
Required GitHub Secrets
Set these in Settings → Secrets and variables → Actions:
| Secret | Description |
|---|---|
IOS_P12_BASE64 |
Base64-encoded .p12 distribution certificate |
IOS_P12_PASSWORD |
Password for the .p12 file |
IOS_PROVISION_PROFILE_BASE64 |
Base64-encoded .mobileprovision file |
IOS_PROVISION_PROFILE_UUID |
UUID of the provisioning profile (filename in ~/Library/MobileDevice/Provisioning Profiles/) |
IOS_PROVISION_PROFILE_NAME |
Name of the provisioning profile (used in Xcode export) |
IOS_TEAM_ID |
Apple Developer Team ID (10-char alphanumeric) |
APP_STORE_API_KEY_ID |
App Store Connect API key ID |
APP_STORE_API_ISSUER_ID |
App Store Connect API issuer ID |
APP_STORE_API_PRIVATE_KEY |
App Store Connect API private key (.p8 content) |
Pipeline steps
- Test (ubuntu-latest) — runs backend + frontend test suites
- Build iOS (macos-14 — Apple Silicon runner):
- Installs Node 20, builds web bundle, syncs Capacitor
- Installs CocoaPods dependencies
- Imports signing certificate + provisioning profile from secrets
- Sets
MARKETING_VERSION/CURRENT_PROJECT_VERSIONfrom tag - Builds
.xcarchive, exports.ipa - Uploads IPA as GitHub artifact (30-day retention)
- Uploads to TestFlight via
xcrun altool
Local iOS build script
For local builds on macOS, use the provided script:
./scripts/build-ios.sh
This performs the same steps (build web, sync, pod install, archive, export IPA) without the CI signing/TestFlight steps. Override the export-options path with:
EXPORT_OPTIONS=path/to/export-options.plist ./scripts/build-ios.sh
Export Options
scripts/export-options.plist configures the IPA export (App Store method, manual signing, team ID, provisioning profile). The workflow replaces placeholder values from secrets at build time.
Release flow
# 1. Tag a release
git tag ios-v1.2.3
git push origin ios-v1.2.3
# 2. GitHub Actions runs automatically:
# test → build → archive → IPA artifact → TestFlight upload
# 3. Download the IPA from the Actions Artifacts section if needed
# 4. Submit via App Store Connect after TestFlight review
Migrating from Bitrise
The bitrise.yml is still present but no longer needed for iOS. To remove it:
rm bitrise.yml
Key differences from the Bitrise pipeline:
| Aspect | Bitrise | GitHub Actions |
|---|---|---|
| Runner | Bitrise macOS stack | macos-14 (M1 Apple Silicon) |
| Signing | Bitrise certificate step | apple-actions/import-codesign-certs |
| TestFlight upload | deploy-to-bitrise-io |
xcrun altool with App Store Connect API |
| Trigger | Tag ios-v* |
Tag ios-v* or manual dispatch |
| Artifact | Bitrise dashboard | GitHub Actions Artifacts (30-day retention) |
| Cache | Bitrise stack caching | actions/setup-node cache |
Updating Capacitor / plugins
npm install @capacitor/core@latest @capacitor/cli@latest \
@capacitor/android@latest @capacitor/ios@latest \
@capacitor/app@latest @capacitor/splash-screen@latest @capacitor/status-bar@latest
npm run cap:sync
After major upgrades follow the migration guide at https://capacitorjs.com/docs/updating.
Troubleshooting
cap syncsaysCould not find platform 'android'— you removed the folder. Re-create withnpx cap add android.- iOS build fails with
Sandbox: rsync ... deny— clean build folder (Cmd-Shift-K), thenpod installinsideios/App. - WebView shows a white screen on Android 14 — verify
android:usesCleartextTrafficisn't blocking your dev backend (HTTPS only by default incapacitor.config.json). - Geolocation never returns — Android 14 requires both
ACCESS_FINE_LOCATIONand a runtime grant via the system dialog; iOS requires theInfo.plistkeys above plus enabling Location on the simulator (Features → Location).