Skip to content

Metricis Mobile Deployment Guide

This guide covers the current Capacitor 8 mobile build, debug, signing, and release workflow for the Metricis assessment client.

For the automated GitHub Actions + Fastlane release path, see Release Automation. This page focuses on local validation, native platform setup, app-store readiness, and deployment-specific checks.

Current Mobile Baseline

Area Current Baseline
Capacitor @capacitor/core, @capacitor/android, @capacitor/ios, and CLI 8.3.x
Node 24.16.0 from .nvmrc
Java 21 for Android builds
Android SDK 36, Android Gradle Plugin 8.13.0, Gradle wrapper 8.14.3
iOS iOS deployment target 15.0; current simulator validation uses Xcode 26.x / iOS 26.5
Bundle ID / package app.metricis.app
Native project root client/android and client/ios/App
Release automation .github/workflows/mobile-release.yml plus fastlane/Fastfile

Mobile app submission is separate from the M7 web cutover. The native projects now build and sync on CI, but store submission still depends on Firebase/APNs configuration, signing assets, store metadata, and sponsor/regulatory release approval.

The native app strategy is single-app for now: the client assessment app is the only Capacitor app. The patient-portal workspace remains a web-only frontend deployed through the normal web path; it should not receive native projects, signing identities, or store listings unless a future roadmap milestone explicitly reopens a two-app strategy.

Prerequisites

Shared

  • Node 24.16.0; use nvm use or install the version from .nvmrc.
  • npm from the selected Node toolchain.
  • Repository dependencies installed with npm install from the monorepo root.
  • No global Capacitor CLI is required; use the workspace scripts below.

Android

  • Android Studio with Android SDK 36 installed.
  • ANDROID_HOME pointing at the SDK, or client/android/local.properties containing sdk.dir=/absolute/path/to/Android/sdk.
  • Java 21 available to Gradle.
  • A release upload keystore for Play Store builds.

iOS

  • macOS with Xcode installed.
  • Xcode command line tools selected with xcode-select.
  • iOS simulator/runtime matching the local Xcode installation.
  • CocoaPods available for local iOS native builds.
  • Apple Developer account, distribution certificate, provisioning profile, and App Store Connect API key for signed releases.

Fastlane

Fastlane dependencies are intentionally isolated under fastlane/Gemfile so normal cap sync ios does not try to resolve release gems.

BUNDLE_GEMFILE=fastlane/Gemfile bundle install

Monorepo Capacitor Commands

Run these from the repository root.

# Sync both native projects after building client assets
npm run cap:sync

# Sync one platform
npm run cap:sync:android
npm run cap:sync:ios

# List or run Android targets
npm run cap:run:android -- --list
npm run cap:run:android -- --target <device-or-emulator-id>

# List or run iOS targets
npm run cap:run:ios -- --list
npm run cap:run:ios -- --target <simulator-name-or-udid>

# Open native IDEs
npm run cap:open:android
npm run cap:open:ios

If you are already inside client/, the equivalent direct command is npx cap run android or npx cap run ios. From the monorepo root, prefer the npm scripts so Capacitor targets the client workspace instead of the repository root.

Local Debug Builds

Android Debug

Build and sync the Android project:

npm run build:android --workspace=client

Assemble a debug APK:

cd client/android
./gradlew assembleDebug --stacktrace

Run on a connected emulator or device:

npm run cap:run:android -- --list
npm run cap:run:android -- --target <device-or-emulator-id>

Typical emulator target names look like Medium_Phone_API_36.1; ADB IDs look like emulator-5554.

iOS Debug

Build and sync the iOS project:

npm run build:ios --workspace=client

Open in Xcode:

npm run cap:open:ios

Or build from the command line without signing:

cd client/ios/App
pod install
xcodebuild -workspace App.xcworkspace \
  -scheme App \
  -configuration Debug \
  -sdk iphonesimulator \
  -destination 'generic/platform=iOS Simulator' \
  CODE_SIGNING_ALLOWED=NO \
  build

Run on a simulator:

npm run cap:run:ios -- --list
npm run cap:run:ios -- --target <simulator-name-or-udid>

Version Management

Use the repository version helper so npm package versions and native metadata stay aligned:

npm run release:bump -- --version=1.2.3 --build=123 --dry-run
npm run release:bump -- --version=1.2.3 --build=123

The helper updates:

  • package.json
  • client/package.json
  • client/android/app/build.gradle
  • client/ios/App/App.xcodeproj/project.pbxproj

Rules:

  • --version must be numeric SemVer: major.minor.patch.
  • --build must be a positive integer.
  • Store uploads require monotonically increasing build numbers.
  • Apply the bump on a branch, validate, open a PR, and release from main.

Release Workflow

Preferred release path:

  1. Land a version-bump PR on main.
  2. Open GitHub Actions.
  3. Run Mobile and web release.
  4. Choose platform, upload_to_stores, android_track, ios_destination, and deploy_landing.
  5. Approve any protected GitHub Environments.
  6. Download retained artifacts or finish store promotion in the store consoles.

See Release Automation for required secrets, dispatch presets, and promotion policy.

Local Release Commands

Android App Bundle

Build a signed Android App Bundle through Fastlane:

BUNDLE_GEMFILE=fastlane/Gemfile bundle exec fastlane android build

Manual Gradle fallback after exporting signing variables:

NODE_ENV=production npm run build --workspace=client
npm run cap:sync:android
cd client/android
./gradlew bundleRelease --stacktrace

The release AAB is written to:

client/android/app/build/outputs/bundle/release/app-release.aab

Upload to Google Play internal testing through Fastlane:

SUPPLY_JSON_KEY_DATA="$(cat google-play-service-account.json)" \
BUNDLE_GEMFILE=fastlane/Gemfile bundle exec fastlane android play track:internal release_status:draft

Google Play usually requires first-time app/listing setup and at least one manual initial binary before API uploads can fully manage tracks.

iOS IPA

Build a signed IPA through Fastlane after exporting signing variables:

BUNDLE_GEMFILE=fastlane/Gemfile bundle exec fastlane ios build

Upload to TestFlight:

BUNDLE_GEMFILE=fastlane/Gemfile bundle exec fastlane ios testflight

Upload to App Store Connect without automatic submission:

BUNDLE_GEMFILE=fastlane/Gemfile bundle exec fastlane ios appstore

The App Store lane uploads the binary but does not submit for review and does not automatically release.

Signing Assets and Secrets

The GitHub Actions release workflow expects these secret groups:

  • Android Firebase config, keystore base64, passwords, alias, and Google Play service account JSON.
  • Apple team ID, distribution certificate, provisioning profile, CI keychain password, and App Store Connect API key.
  • Cloudflare secrets only when deploying the landing site.

Use Release Automation as the authoritative secret list.

Never commit keystores, signing certificates, provisioning profiles, API keys, or store service-account JSON.

Push Notifications

iOS APNs

The iOS project includes push-notification entitlements and forwards APNs registration callbacks to Capacitor. Before release:

  1. Create an APNs key or certificate in Apple Developer.
  2. Confirm the App ID has Push Notifications enabled.
  3. Configure backend APNs credentials.
  4. Smoke test device token registration through POST /api/notifications/register.

Android FCM

Android still requires a project-specific Firebase configuration file:

client/android/app/google-services.json

Generate it from Firebase Console for package app.metricis.app. Use client/android/app/google-services.json.example as a shape reference only; do not copy the placeholder file into a release build.

For local release validation, place the real file at client/android/app/google-services.json. The file is ignored by Git so project-specific Firebase values do not get committed accidentally.

For GitHub Actions release builds, base64-encode the real file and save it as ANDROID_GOOGLE_SERVICES_JSON_BASE64:

base64 -i client/android/app/google-services.json | pbcopy

Release builds fail fast when the Firebase config is missing. Debug builds may omit it, but Android push notifications will not work without it.

Troubleshooting

android platform has not been added yet

Run Capacitor from the client workspace or use the root workspace script:

npm run cap:sync:android
npm run cap:run:android -- --list

Running npx cap run android from the monorepo root looks for android/ at the wrong level.

Android SDK location not found

Set ANDROID_HOME:

export ANDROID_HOME="$HOME/Library/Android/sdk"

Or create client/android/local.properties:

sdk.dir=/Users/<username>/Library/Android/sdk

invalid source release: 21

Gradle is using a Java runtime older than Java 21. Point Gradle at Java 21:

export JAVA_HOME=$(/usr/libexec/java_home -v 21)

Or add to client/android/gradle.properties locally:

org.gradle.java.home=/Library/Java/JavaVirtualMachines/amazon-corretto-21.jdk/Contents/Home

Do not raise the Android Java target to a newer local JDK just because it is installed. Capacitor 8 and the Android toolchain are validated here on Java 21.

ADB install fails after Gradle succeeds

The APK built, but deployment to the emulator/device failed. Check:

adb devices
adb -s <device-id> install -r client/android/app/build/outputs/apk/debug/app-debug.apk
adb logcat

Common causes are a stale emulator session, a mismatched installed package signature, insufficient device storage, or an emulator that has not fully booted.

CocoaPods or Fastlane gems interfere with cap sync ios

There should not be a root Gemfile. Fastlane dependencies live under fastlane/Gemfile.

Use:

BUNDLE_GEMFILE=fastlane/Gemfile bundle exec fastlane ...

Normal iOS sync remains:

npm run build:ios --workspace=client

WebView cannot reach local API from a device

Use your workstation LAN IP for native builds:

VITE_NATIVE_API_URL=http://192.168.1.xxx:8030/api npm run build:android --workspace=client

Android debug builds include a debug-only cleartext network security config. Release builds must use HTTPS.

Security and Store Readiness

  • WebView debugging is disabled in source for production posture.
  • Android backup is disabled so local clinical data is not copied to Google backup.
  • Android release builds use ProGuard keep rules for Capacitor bridge/plugin classes.
  • Release signing assets must be stored in GitHub Secrets or local secure storage only.
  • Production store uploads should require GitHub Environment approval and sponsor/regulatory sign-off.

The repo ships client/public/.well-known/apple-app-site-association (AASA), which Vite copies into client/dist/.well-known/apple-app-site-association. Apple's CDN fetches it once at app install / first launch and caches it.

1. Replace the TEAMID placeholder

The committed AASA contains the placeholder appID TEAMID.app.metricis.app. Before the file is served from production, replace TEAMID with the 10-character Apple Developer Team ID from App Store Connect > Membership. The deployed file must look like:

{ "appIDs": ["ABCDE12345.app.metricis.app"] }

The placeholder fails verification by design. Apple will refuse to validate the domain until the real Team ID is in place.

2. nginx serving rules

Apple requires the AASA file to be served:

  • With Content-Type: application/json.
  • Without a .json extension.
  • Without any redirect.
  • Over HTTPS with a valid certificate.

Example nginx snippet:

location /.well-known/apple-app-site-association {
    default_type application/json;
    try_files $uri =404;
    add_header Cache-Control "no-cache" always;
}

3. Validation

curl -sI https://app.metricis.app/.well-known/apple-app-site-association
# Expect: 200 OK, Content-Type: application/json, no Location header

Also run Apple's parser through a trusted AASA validator before release.

4. Path components

The AASA currently claims:

  • /study/*/start
  • /participant/*
  • /session/resume/*
  • /battery/*
  • /?link=*
  • /?study=*
  • /?participant=*

These mirror client/src/services/mobile/deepLinking.ts. If a new deep-link route is added there, add a matching AASA component.

The repo ships client/public/.well-known/assetlinks.json, which Vite copies into client/dist/.well-known/assetlinks.json. Google's verifier fetches it from https://app.metricis.app/.well-known/assetlinks.json when the app is installed and on signed APK/AAB updates.

1. Replace the fingerprint placeholder

The committed file uses a placeholder in sha256_cert_fingerprints. Before production, replace it with the SHA-256 fingerprint of the release signing keystore:

keytool -list -v \
  -keystore /path/to/metricis-release-key.jks \
  -alias metricis | grep "SHA256:"

For debug App Links testing, append the debug keystore fingerprint too:

keytool -list -v \
  -keystore ~/.android/debug.keystore \
  -alias androiddebugkey \
  -storepass android | grep "SHA256:"

The placeholder fails verification by design so a wrong fingerprint is not silently accepted.

2. nginx serving rules

Google requires assetlinks.json to be served:

  • With Content-Type: application/json.
  • Over HTTPS with a valid certificate.
  • With no redirect.
  • With the literal filename assetlinks.json.

Example nginx snippet:

location = /.well-known/assetlinks.json {
    default_type application/json;
    try_files $uri =404;
    add_header Cache-Control "no-cache" always;
}

3. Validation

curl -sI https://app.metricis.app/.well-known/assetlinks.json
# Expect: 200 OK, Content-Type: application/json, no Location header

curl -s "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://app.metricis.app&relation=delegate_permission/common.handle_all_urls"

On a signed installed build:

adb shell pm get-app-links app.metricis.app
# Expect: app.metricis.app: verified

4. Path patterns

The Android intent filter claims:

  • /study/.*/start
  • /participant/.*
  • /session/resume/.*
  • /battery/.*
  • /

Magic-link URLs use query strings such as /?link=...; Android pathPattern does not match query strings, so the root path entry is what dispatches those links to the app.

5. autoVerify failure mode

If assetlinks.json is missing, malformed, served with the wrong content type, or contains a mismatched fingerprint, autoVerify fails silently. Android keeps the intent filter active but may show a chooser instead of opening Metricis automatically. Re-check adb shell pm get-app-links app.metricis.app after every release-signing change.