Skip to content

Mobile Builds

Native iOS and Android builds using Capacitor 8, Fastlane, and GitHub Actions.

Live store listings: iOS — https://apps.apple.com/us/app/objectuve-every-day-adds-up/id6777474045 (in App Store review; live on approval) · Android — https://play.google.com/store/apps/details?id=com.objectuve.ionic

Account Setup Checklist

Before running mobile builds, ensure these accounts and credentials are active:

Apple Developer

  1. Log into developer.apple.com and renew membership if expired ($99/yr)
  2. Verify app ID com.objectuve.ionic is registered under Certificates, IDs & Profiles > Identifiers
  3. Create an app entry in App Store Connect if one doesn't exist
  4. Create an App Store Connect API key:
    • Go to Users and Access > Integrations > App Store Connect API
    • Generate a key with "App Manager" role
    • Download the .p8 file (only downloadable once)
    • Note the Key ID and Issuer ID

Google Play

  1. Log into Google Play Console
  2. Create the app listing for com.objectuve.ionic if it doesn't exist
  3. Create a service account for API access:
    • Go to Setup > API access
    • Link to Google Cloud project or create a new service account
    • Grant "Release Manager" permissions
    • Download the JSON key file

Code Signing

iOS (Fastlane Match):

  1. Create a private git repo for certificate storage (e.g., github.com/joshlock3/enkidu-certificates)
  2. Run initial Match setup from ionic_frontend/:
    bash
    bundle install
    MATCH_GIT_URL=git@github.com:joshlock3/enkidu-certificates.git \
    APPLE_ID=josh@objectuve.com \
    bundle exec fastlane match appstore
  3. This generates and stores App Store distribution certificates and provisioning profiles
  4. For development certificates: bundle exec fastlane match development
  5. Team members download existing certs with: bundle exec fastlane match appstore --readonly

Android:

  1. Generate a release keystore (one-time):
    bash
    keytool -genkey -v -keystore release.keystore -alias objectuve \
      -keyalg RSA -keysize 2048 -validity 10000
  2. Store the keystore securely — it cannot be regenerated. If lost, the app must be re-published under a new signing key.
  3. Base64-encode for CI: base64 -i release.keystore | pbcopy

Local Development

Prerequisites

  • Xcode 16+ (iOS) — install from Mac App Store
  • Android Studio (Android) — Ladybug or later with SDK 36
  • JDK 21brew install openjdk@21 (matches CI's temurin/21 setup; see the JDK 21 Gradle gotcha below for the shared-runner invocation)
  • Ruby 3.x — for Fastlane
  • Node 22 — for web build (matches CI's actions/setup-node@v6 config)

First Time Setup

bash
cd ionic_frontend
npm ci
gem install bundler
bundle install
npm run build:mobile

Running Locally

iOS:

bash
npm run build:mobile:ios    # Build web + sync to iOS
npm run cap:open:ios         # Open in Xcode
npm run cap:run:ios          # Build and run on simulator

Android:

bash
npm run build:mobile:android  # Build web + sync to Android
npm run cap:open:android       # Open in Android Studio
npm run cap:run:android        # Build and run on emulator

Hot Reload (Development)

For faster iteration during development:

bash
npm run dev                  # Start Vite dev server
# In another terminal:
npx cap run ios --livereload --external
# or
npx cap run android --livereload --external

Version Management

Source of Truth

The version in ionic_frontend/package.json is the single source of truth for the app version number across web, iOS, and Android.

Sync Script

ionic_frontend/scripts/sync-version.cjs propagates the version to native projects:

bash
npm run sync-version                          # Auto build number
npm run sync-version -- --build-number 42     # Explicit build number

The script updates:

  • iOS: MARKETING_VERSION and CURRENT_PROJECT_VERSION in project.pbxproj
  • Android: versionName and versionCode in build.gradle

Build Numbers

  • In CI: Uses $GITHUB_RUN_NUMBER (auto-incrementing integer per workflow)
  • Locally: Falls back to a timestamp-based number
  • Can be overridden: --build-number N

Versioning Convention

Per project conventions:

  • Update the version in package.json when a branch is first pushed
  • Increment the version on every direct push to master

CI/CD Pipeline

Workflows

WorkflowTriggerRunnerFile
Android Build & DeployPush of v* tag + manualubuntu-latestmobile-android.yml
Android Direct-Install Release BuildPush of v* tag + manualubuntu-latestmobile-android-release.yml
android-widget-snapshots (Widget Snapshot Regression Gate)PR touching ionic_frontend/android/**ubuntu-latestmobile-android.yml
Android Release Build & Boot Smoke (proguard-keep-rules-guard + boot-smoke)PR touching ionic_frontend/android/** (branches master, gsd/*)ubuntu-latestmobile-android-boot-smoke.yml
iOS Build & DeployManual onlymacos-26mobile-ios.yml
ios-signing-check (Verify iOS signing assets)PR touching ionic_frontend/ios/** or ionic_frontend/fastlane/**macos-26mobile-ios.yml
ios-widget-extension-build (Build HomeWidgetExtension Target)PR touching ionic_frontend/ios/**macos-26mobile-ios.yml
ios-widget-snapshots (Widget Snapshot Tests)PR touching ionic_frontend/ios/**macos-26mobile-ios.yml
iOS Release Build & Boot Smoke (boot-smoke + plugin-registration-check)PR touching ionic_frontend/ios/** (branches master, gsd/*)macos-26mobile-ios-boot-smoke.yml
Mobile Viewport Smoke TestsAfter production deploy + manualubuntu-latestplaywright-smoke.yml
Capacitor Sync Drift (check)PR touching package.json/package-lock.json/android/**/ios/** + push to masterubuntu-latestcap-sync-drift.yml

Android Pipeline

Runs automatically on push of a v* tag (e.g. v3.12.15):

  1. Build web assets with production env vars
  2. cap sync android
  3. Sync version from package.json (versionName); versionCode computed from seconds-since-2024-01-01
  4. Build release AAB with signing
  5. Upload to Google Play internal testing track (fastlane android beta)
  6. Once that upload succeeds, promote-android auto-promotes the same build straight to production (see Promoting to production below) — a production tag ships to Play production in one go.

Manual workflow_dispatch also supports track: internal (default) or track: production — see below for when the manual production dispatch is the right tool.

Promoting to production

Production promotion is automatic: mobile-android.yml's promote-android job needs: build-android and fires whenever build-android succeeds on a v* tag push. It calls fastlane android promote version_code:${{ needs.build-android.outputs.build_number }} — pinned to the exact versionCode build-android just uploaded to internal, so it can never promote a stale build. This applies to every production tag, including hotfix tags — there's no separate "internal-only" tag shape; a tag push is the audit trail (visible in Actions), same as before.

Manual workflow_dispatch --track=production survives as an escape hatch only — e.g. re-running a promote that failed, or promoting outside the tag flow — not by clicking through the Play Console, which never touches this workflow and leaves no Actions record. Dispatching it manually runs the same promote lane but without the version_code pin the auto-promote path uses (ionic_frontend/fastlane/Fastfile:174-182), so it promotes whatever build is currently sitting on the internal track — check what's live on internal before using this path.

R8 Minification (Release Builds, OBJ-1506)

The release buildType in ionic_frontend/android/app/build.gradle runs with minifyEnabled true and shrinkResources true (previously minifyEnabled false — R8 was off). Verified locally via assembleRelease: 19.5MB → 13.4MB (~31% smaller) APK, zero R8-stripping crash signatures in logcat, HomeWidgetProvider confirmed live under R8.

Keep rules (ionic_frontend/android/app/proguard-rules.pro) protect the reflection-invoked paths R8's static analysis can't see:

  • @android.webkit.JavascriptInterface-annotated methods app-wide — the WebView-to-native bridge. com.getcapacitor.MessageHandler doesn't extend com.getcapacitor.Plugin, so it falls outside Capacitor's own consumerProguardFiles keep rules.
  • WidgetBridgePlugin / HomeWidgetProvider — defense-in-depth on top of existing AGP/Capacitor default coverage (the @CapacitorPlugin/manifest-registered BroadcastReceiver keep rules already cover these; this is a belt-and-suspenders explicit -keep).
  • com.google.firebase.messaging.** and com.revenuecat.purchases.** — defensive -keep/-dontwarn for Firebase Messaging (push) and RevenueCat (useInAppPurchases.ts), since the resolved AAR contents weren't verified in the environment these rules were authored in.

Before shipping to Production: no real CI-built AAB with R8 has run yet — mobile-android.yml's build-android job only fires on push of a v* tag or manual workflow_dispatch (see Android Pipeline above), never on pull_request. Local verification used ./gradlew assembleRelease (APK), not bundleRelease (AAB) — both tasks share the same buildTypes.release flags, so it's a valid proxy, but isn't a substitute for the real signed-AAB pipeline. Before promoting to track: production, trigger mobile-android.yml manually with track: internal for one real CI-built AAB pass, and run a native-feature regression (push notifications, camera, share, local notifications, keyboard, widget bridge) on that build. R8 stripping is a runtime risk — a missing keep rule breaks a reflection-invoked path silently, not at build time — so device regression matters more than a green build.

Rollback: if R8 breaks a native feature in the field, flip minifyEnabled back to false in the release buildType and re-release — single-line revert.

Direct-Install Android Builds

Runs automatically on every production tag push (v*):

  1. Build web assets with production env vars (matches production.yml)
  2. cap sync android and sync version from the tag (--version ${TAG#v})
  3. Build a signed universal APK with the upload keystore (./gradlew :app:assembleRelease)
  4. Upload to gs://enkidu-mobile-builds/android/objectuve-vX.Y.Z-<run>.apk via WIF
  5. Prune the bucket to the most recent 10 APKs
  6. Generate a 12-hour V4 signed URL via gcloud storage sign-url --impersonate-service-account (12h is the GCS ceiling under WIF/signBlob; longer would require a downloaded JSON key)
  7. Wait for production.yml to publish the GitHub Release, then append the download link to the release body

The signed URL is also written to the workflow summary in case the GitHub Release is never created (e.g. production deploy failed). Open the link on the target Android device → Chrome downloads the APK → tap to install (sideloading must be enabled).

Refreshing an expired URL: the URL is valid for 12 hours. To mint a new one for an existing build, re-run the workflow via gh workflow run mobile-android-release.yml -f tag=v3.9.79 (or the GitHub Actions UI). The release notes section is replaced in-place via an HTML marker comment.

The APK is signed with the upload keystore, not the Google Play app-signing key, so devices installed via this URL will not auto-update from the Play Store. For real users, distribute via Play.

One-time setup:

bash
./gcp_setup/04_create_mobile_builds_bucket.sh

Creates gs://enkidu-mobile-builds, grants storage.objectAdmin to the deployer SA, and grants iam.serviceAccountTokenCreator to the SA on itself (needed for signBlob-based signed URL generation under WIF). Idempotent.

iOS Pipeline

Manual dispatch only (macOS runners are 10x the cost of Linux):

  1. Build web assets with production env vars
  2. cap sync ios
  3. Sync version from package.json
  4. Fetch signing certs via Fastlane Match
  5. Build signed IPA
  6. Upload to TestFlight or App Store

iOS Signing Health Check (ios-signing-check)

Added in v4.1 Phase 4 (home-screen widgets, OBJ-1148) after a new WidgetKit extension bundle ID (com.objectuve.ionic.HomeWidget) landed with no CI gate exercising its provisioning. Before this, a missing/broken Developer Portal registration or match-repo entry for an app identifier would only surface when someone manually dispatched a real TestFlight/App Store build.

Job: ios-signing-check in mobile-ios.yml — runs automatically on any PR touching ionic_frontend/ios/** or ionic_frontend/fastlane/** (the full build-ios job stays workflow_dispatch-only). Read-onlybundle exec fastlane ios signing_check calls match(readonly: true) for every registered app identifier and never builds or uploads, so it can't mutate certs/profiles.

  • Passes → all registered identifiers already have valid Developer Portal registration + match signing assets.
  • Fails → a new identifier needs Developer Portal registration (App ID + any new capabilities, e.g. App Groups) and a one-time non-readonly fastlane match appstore run. Only a human with Apple Developer Portal access can do this — no agent holds those credentials.

This is a general-purpose gate, not widget-specific — it will catch the same gap for any future app extension (share extension, Siri intents, etc.) that needs its own bundle ID.

iOS Widget Extension Build Check (ios-widget-extension-build, OBJ-1376)

Added after HomeWidgetViews.swift shipped without import WidgetKit and no CI job caught it — the ios-widget-snapshots test harness only compiled the extension target incidentally, as a side effect of building its test dependencies, so a real compile failure in the extension itself wasn't guaranteed to surface.

Job: ios-widget-extension-build in mobile-ios.yml — runs automatically on any PR touching ionic_frontend/ios/**, independent of the snapshot-testing harness:

bash
xcodebuild build \
  -project App.xcodeproj \
  -target HomeWidgetExtension \
  -destination 'generic/platform=iOS Simulator' \
  CODE_SIGNING_ALLOWED=NO

No artifacts, no PNGs — this is a pure compile gate. A missing import or other build-only regression in the extension target fails this job directly rather than relying on the snapshot harness to notice as a side effect.

Release-Build Safety Gates (v4.25)

Three pre-merge CI gates close the gap that let OBJ-1899 ship undetected — a v4.3.0 production incident where R8 stripped WorkManager's reflectively-used WorkDatabase_Impl.<init>, force-closing the app on launch. Before this milestone, every PR-triggered mobile job built debug artifacts only; the jobs that did assemble release/minified artifacts (assembleRelease, the tag-only fastlane iOS archive) only assembled or uploaded — nothing ever booted the result. Each gate below is a boot-and-check-no-crash smoke, not full E2E — that's enough to catch this bug class.

1. Capacitor native-registration drift gate (cap-sync-drift.yml)

What it catches: an npm-installed @capacitor/* plugin whose native registration files were never regenerated — the near-miss shape behind PR #1877, which added app-launcher/share to package.json with zero native-file changes. npx cap sync generates native registration glue from the npm dependency graph; skip it (or forget to commit its output) and the plugin ships compiled into node_modules but never wired into the native project.

How it works: on any PR (and push to master) touching ionic_frontend/package.json, package-lock.json, android/**, or ios/**, the check job runs npm ci, then npx cap sync android && npx cap sync ios, then diffs exactly the 3 git-tracked, cap-sync-generated files against what's committed:

  • ionic_frontend/android/capacitor.settings.gradle
  • ionic_frontend/android/app/capacitor.build.gradle
  • ionic_frontend/ios/App/CapApp-SPM/Package.swift

Any diff fails the job. ios/App/App/capacitor.config.json is deliberately not part of this diff — it's gitignored and regenerated fresh on every CI run, so a stale copy can never exist in git.

Satisfy it locally after installing or removing a @capacitor/* package:

bash
cd ionic_frontend
npm ci
npm run cap:sync   # or cap:sync:android / cap:sync:ios individually
git status         # the 3 tracked files above should show no diff, or a diff you're about to commit

Commit the resulting diff. This is now a hard contributor rule — see CONTRIBUTING.md § Capacitor Plugin Installs.

2. Android release build & boot smoke (mobile-android-boot-smoke.yml)

What it catches: a release-only Android startup crash from R8/minification stripping a reflectively-used class — the OBJ-1899 failure shape. No PR job previously assembled a minified build at all: mobile-android.yml's PR job only runs verifyRoborazziDebug (JVM/Robolectric, debug config), and mobile-android-release.yml's minified assembleRelease is tag-only and never boots what it builds.

How it works — two jobs, both triggered on any PR touching ionic_frontend/android/**:

  • proguard-keep-rules-guard — a fast, cheap defense-in-depth check that greps proguard-rules.pro for the three WorkManager/Room keep rules (RoomDatabase, androidx.work.impl.**, ListenableWorker) added under PR #1877. It only proves the rule text wasn't dropped, not that the app survives R8 with it — that's the next job's job.
  • boot-smoke — builds web assets, cap sync android, then ./gradlew :app:assembleRelease (debug-signed: no ANDROID_KEYSTORE_PATH is set, so signing falls back to the debug config, but minifyEnabled/shrinkResources stay on regardless). Verifies an R8 mapping.txt was actually produced (proof minification ran), then installs the APK on a reactivecircus/android-emulator-runner emulator (API 34, Pixel 6) and runs android/scripts/boot-smoke-check.sh: launch MainActivity, wait 10s, assert the process is still alive and logcat shows no FATAL EXCEPTION/ANR.

Satisfy it locally:

bash
cd ionic_frontend
npm run build:mobile:android   # build + cap sync android
cd android
./gradlew :app:assembleRelease
# install app/build/outputs/apk/release/app-release.apk on a device/emulator and confirm it launches and stays up

Don't weaken minifyEnabled/shrinkResources to make this pass — the whole point is exercising the R8 codepath. If a new reflectively-invoked class needs to survive R8, add a keep rule to proguard-rules.pro instead.

3. iOS release boot smoke + plugin-registration check (mobile-ios-boot-smoke.yml)

Two separate jobs — a missing plugin registration doesn't crash the app, so the boot smoke below can't catch it on its own.

3a — boot-smoke (what it catches): the iOS analog of gate 2 — a crash that only reproduces in an optimized Release build. No PR job previously compiled the App target at all (mobile-ios.yml only builds HomeWidgetExtension); cap sync ios and the fastlane release archive lived only in the dispatch-only deploy job, which assembles but never boots.

How it works: builds web assets, cap sync ios, then xcodebuild build -scheme App -configuration Release -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO. A "Verify Release build is optimized" step reads back SWIFT_OPTIMIZATION_LEVEL/SWIFT_COMPILATION_MODE and fails if the Release config resolved to -Onone — closing the "weaken Release settings to pass" loophole. Then ios/scripts/boot-smoke-check.sh boots an "iPhone 17 Pro" simulator, installs and launches the app, waits 10s, and asserts the process is still running.

3b — plugin-registration-check (what it catches): Capacitor 8 does not scan the Objective-C runtime for plugins. CapacitorBridge.registerPlugins() seeds a hardcoded core list, then resolves everything in the synced capacitor.config.json's packageClassList — a list cap sync generates from installed npm packages only. A plugin whose source lives in the App target itself (AppleSignInPlugin, WidgetBridgePlugin) has to self-register via bridge?.registerPluginInstance(_:) in MainViewController.capacitorDidLoad() (MainViewController.swift) — miss that call and the plugin compiles and ships fine, but calling it from JS rejects with UNIMPLEMENTED at call time. That's invisible to 3a's boot smoke (the app doesn't crash), so it gets its own check.

How it works: AppTests/PluginRegistrationTests.swift reflects over the App target's compiled classes (via objc_copyClassNamesForImage, scanning only images loaded from the app bundle root) to find every in-target class conforming to CAPBridgedPlugin, reads packageClassList from the synced capacitor.config.json for the npm side, and asserts every one of them resolves through the live bridge (bridge.plugin(withName:)) after MainViewController.loadViewIfNeeded(). Runs via xcodebuild test -scheme App -only-testing:AppTests.

This gate is proven, not theoretical — it caught a real live bug on landing: WidgetBridgePlugin was compiled into the App target but never registered, exactly the failure mode described above.

Satisfy either half locally:

bash
cd ionic_frontend
npm run build:mobile:ios   # build + cap sync ios
cd ios/App

# 3a — boot smoke
xcodebuild build -project App.xcodeproj -scheme App -configuration Release \
  -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO

# 3b — plugin registration
xcodebuild test -project App.xcodeproj -scheme App -only-testing:AppTests \
  -destination 'platform=iOS Simulator,name=iPhone 17 Pro' CODE_SIGNING_ALLOWED=NO

If 3b fails on a new in-target plugin, add bridge?.registerPluginInstance(<Plugin>()) to MainViewController.capacitorDidLoad(). If it fails on an npm plugin, re-run npx cap sync ios and commit the result.

4. iOS device/QA lane — staging dispatch option (OBJ-2507)

The workflow_dispatch path of this same boot-smoke job takes an environment input (production default, staging opt-in). A staging dispatch builds and boots the Release simulator build against staging.api.objectuve.com using secrets.VITE_CLERK_PUBLISHABLE_KEY — the same staging Clerk key playwright-smoke.yml/staging.yml already use, so no new secret was provisioned, only wiring. pull_request runs and a production-targeted dispatch are unaffected: github.event.inputs.environment is unset on pull_request, and the == 'staging' && x || y expressions in the "Build Web Assets" step resolve identically to pre-lane behavior in that case.

Dispatch it: Actions → iOS Release Build & Boot Smoke → Run workflow → environment: staging. A manual run also uploads one post-boot simulator screenshot as a build artifact (ios-boot-smoke-capture-<environment>, 14-day retention) — the closest thing this lane has to visual evidence for a handoff comment.

What this closes: this repo had no way to build and boot an iOS simulator against a real backend + real (staging) credentials from an agent workdir — every manual dispatch of this job built against production only. The environment input closes that gap for the reachable parts of a manual iOS pass: build, boot, install, launch, one static screenshot.

What this does NOT close — named explicitly per Josh's OBJ-2507 maintainer decision, not silently absorbed:

  1. Staging is not guaranteed to stay a faithful rehearsal of production auth. scripts/clerk-auth-config.expected.json's staging_accepted_divergences list is currently empty — the three divergences previously tracked here (first_name.required, last_name.required, an extra Facebook OAuth option for QA) all converged under OBJ-2638, and the previously reported Apple/passkey/device-verification divergence is no longer observable either: a 2026-08-17 probe found Apple OAuth enabled and passkey settings identical on both instances, and sign_in.second_factor.required is false on both (OBJ-2535/OBJ-2638). Staging is held to full parity with production on every path scripts/check-clerk-auth-config.mjs compares. A green staging run proves the app boots and doesn't crash against staging — it does not prove sign-in behaves identically to production, since a new divergence could be accepted here in the future without invalidating that boot-smoke guarantee.
  2. iOS Password AutoFill and a full tap-to-dashboard recording are still not covered. ios/scripts/boot-smoke-check.sh drives no UI interaction — it boots, installs, launches, and asserts the process survives a 10s window; the manual-run screenshot is one static frame of whatever the app happens to render at that point (typically the sign-in screen). Confirming AutoFill behavior or recording a real sign-in flow needs UI automation (e.g. XCUITest) driving a live sign-in while simctl io recordVideo captures — that's OBJ-2508's job to build on top of this lane, not this job's.

Why CocoaPods was never part of this gap: OBJ-2507 was opened believing CocoaPods provisioning + a committed Podfile.lock were the blocking piece — a reasonable inference from pod: command not found in a fresh agent workdir, but wrong for this repo. ionic_frontend/ios/App integrates Capacitor and its native plugins via Swift Package Manager (ios/App/CapApp-SPM/Package.swift, "DO NOT MODIFY — managed by Capacitor CLI commands"). There has never been a Podfile here, and npx cap sync ios never shells out to CocoaPods — this job's own npm ci && npm run build && npx cap sync ios && xcodebuild build pipeline proves that on every PR touching ios/**, with zero CocoaPods involvement. scripts/check-ios-no-cocoapods.mjs (run in ios-cocoapods-drift-guard.yml, triggered on PRs/pushes touching ionic_frontend/ios/** or the guard script itself) fails CI if a Podfile/Podfile.lock is ever committed under ionic_frontend/ios, or if CapApp-SPM/Package.swift disappears — so this misdiagnosis can't quietly resurface for the next agent who hits the same pod symptom.

Mobile Smoke Tests

Runs existing Cypress smoke tests at mobile viewport sizes (iPhone 14: 375x812, iPhone 15 Pro: 390x844). Triggered automatically after production frontend deploys and available for manual dispatch.

Widget Snapshot Evidence (CI)

Added in v4.1 Phase 4 (OBJ-1200) to close the OBJ-1148 UI-evidence waiver — the shared runner's iOS Simulator tooling and headless Android emulator can't render a real home-screen widget picker, so native widget evidence now comes from deterministic snapshot rendering in CI, never an emulator home screen. Both jobs run automatically on any PR touching their platform's widget code; neither requires a device, emulator, or simulator UI interaction. The two platforms are symmetric as of OBJ-1546: Android has been a real pixel-diff regression gate against committed goldens since OBJ-1364/OBJ-1373, and iOS is now the same — WidgetSnapshotTests (248 renders, OBJ-1519/OBJ-1520/OBJ-1521/OBJ-1523/OBJ-1595) and HomeWidgetAccentedTests (2 states, the .accented a11y-degradation gate) both diff against committed baseline PNGs, with xcodebuild's own exit code as the real pass/fail signal. iOS previously wrote PNGs in record-only mode with no pixel-diff assertion — see the record-mode gotcha below for what changed and why.

android-widget-snapshots (job name Widget Snapshot Regression Gate (Roborazzi), mobile-android.yml, ubuntu-latest, path-filtered to ionic_frontend/android/**):

  1. Build web assets + cap sync android (same steps as build-android, minus signing/upload).
  2. ./gradlew :app:verifyRoborazziDebug — renders home_widget_small.xml / home_widget_medium.xml via Roborazzi (Robolectric on the JVM, no emulator) and diffs every render against the goldens committed at ionic_frontend/android/app/src/test/snapshots/, failing the build on any pixel difference beyond a 2% per-image tolerance (WidgetSnapshotTest.kt's ROBORAZZI_OPTIONS, tuned to absorb sub-1% vector-drawable anti-aliasing jitter without masking real layout regressions). Coverage: all 5 widget states × 2 sizes × light/dark (20 renders), plus a height × fontScale regression matrix (4 medium heights / 2 small heights × 3 font scales × 5 states, light mode only — layout geometry is theme-independent) and a stale-caption short/long length stress case at 2 heights. Genuinely exits 0 on success — no special exit-code handling needed (unlike the iOS job below).
  3. On a mismatch, uploads the _actual/_compare diagnostic PNGs (app/build/outputs/roborazzi/*.png) as the android-widget-snapshots-diff artifact, if: failure(), if-no-files-found: warn. A fully green run has nothing to upload here — unlike the record-mode job this replaced, evidence only appears on failure.

Updating goldens after an intentional widget change: run ./gradlew :app:recordRoborazziDebug locally (same JDK 21 override as any other Gradle invocation — see Shared-runner gotchas below) to re-record every PNG under app/src/test/snapshots/, review the diff, and commit the updated goldens alongside your change.

The pre-existing build-android job (real signed Play Store upload) is guarded with if: github.event_name != 'pull_request' so the pull_request trigger on this workflow doesn't make it fire on every PR.

ios-widget-snapshots (job name Widget Snapshot Tests (swift-snapshot-testing), mobile-ios.yml, macos-26, inherits the workflow's existing ionic_frontend/ios/** / ionic_frontend/fastlane/** path filter — same trigger as ios-signing-check):

  1. npm ci in ionic_frontend — required even though the test target has no Capacitor dependency, because App.xcodeproj resolves several Capacitor plugins as local SPM packages under ionic_frontend/node_modules/; without it, package resolution fails before the test target ever builds.
  2. xcodebuild test -scheme HomeWidgetExtensionTests — runs both WidgetSnapshotTests (248 assertions across five state matrices: 8 legacy states x SmallWidgetView/MediumWidgetView/LargeWidgetView x light/dark, OBJ-1367/OBJ-1520; 15 kindling states — the same six HomeWidgetState cases crossed with StreakReactiveState x StreakMetaphor where reachable, OBJ-1519/OBJ-1547/OBJ-1523 — x 3 families x light/dark; 2 large-overflow renders, OBJ-1520; 15 Lock Screen accessory states x .accessoryCircular/.accessoryRectangular/.accessoryInline x light/dark, OBJ-1521/OBJ-1523; and 18 flag-off (reactive-absent) Lock Screen accessory renders — the same 3 accessory families x 6 states, single vibrant appearance only, no light/dark split, OBJ-1595) and HomeWidgetAccentedTests (2 assertions: habit-pip-accented-{done,not-done}, the .accented a11y-degradation gate) via swift-snapshot-testing, diffing against the 250 baseline PNGs committed under __Snapshots__/{WidgetSnapshotTests,HomeWidgetAccentedTests}/. As of OBJ-1546, isRecording is false (the default) in both files and xcodebuild's own exit code is the real pass/fail gate — genuinely non-zero on a pixel mismatch, finally matching Roborazzi's Android behavior. See the record-mode gotcha below for the previous record-only shape and why it changed.
  3. Two independent PNG-count checks — Verify widget snapshot evidence (expects 248, computed from the five matrices' state/family/mode counts — see mobile-ios.yml's own inline comments, which are the source of truth for the current numbers) and Verify accented widget snapshot evidence (expects 2) — are secondary sanity guards layered on top of the real xcodebuild gate: they catch a missing/emptied baseline directory degrading into a diff error buried in Swift test output, they are not themselves the pass/fail signal anymore.
  4. A filename-normalization step reconstructs the <surface>--<mode>--<size>.png convention for the size-based testAllWidgetStates PNGs only (see the sanitizePathComponent gotcha below) — testAllWidgetStates_kindlingEnabled, testLargeOverflow_motivatorNotClipped, testAccessoryStates, and testAccessoryStates_flagOff PNGs keep their raw assertSnapshot filenames (test-method-prefixed, single dashes), matching what's actually committed under __Snapshots__/WidgetSnapshotTests/ for those four matrices. HomeWidgetAccentedTests' PNGs keep their raw assertSnapshot filenames too — they're verified in place, not normalized or uploaded.
  5. A legibility gateVerify widget snapshot legibility, running scripts/check-png-legibility.mjs over __Snapshots__/ — fails the job on a blank / near-invisible baseline. Unlike the count checks in (3), this one inspects pixels: it composites each PNG onto the backdrop its filename implies (dark/light token) and fails one with effectively no ink there. The Android job has the same step after verifyRoborazziDebug. See Widget snapshot legibility gate failure for what to do when it fires.
  6. Uploads the 248 WidgetSnapshotTests PNGs as the ios-widget-snapshots artifact, if-no-files-found: error.

Running either harness locally: both jobs' commands work unmodified outside CI — cd ionic_frontend && npm ci && npx cap sync <ios|android> first (both harnesses render against the synced Capacitor project), then Android: cd android && ./gradlew :app:verifyRoborazziDebug -Dorg.gradle.java.home=<JDK 21 path> (see Shared-runner gotchas below); iOS: cd ios/App && xcodebuild test -project App.xcodeproj -scheme HomeWidgetExtensionTests -destination 'platform=iOS Simulator,name=iPhone 17 Pro' (PNGs land under HomeWidgetExtensionTests/__Snapshots__/WidgetSnapshotTests/, un-normalized — see the filename-sanitizer gotcha below for why the local names don't match the CI artifact names verbatim).

Artifact naming: both platforms' core-state PNGs follow <surface>--<mode>--<size>.png (e.g. home-widget-all-done--dark--medium.png), light+dark mandatory, per the ui-evidence-and-critique skill's native-surface convention (see multica-workspace-skills.md § 8). Android's regression-matrix and stale-caption-stress renders use their own descriptive names (home-widget-matrix-<slug>--<size>--h<height>dp--fs<scale>.png, home-widget-stale-caption-<short|long>--h<height>dp.png) — see WidgetSnapshotTest.kt.

Reading evidence for a handoff comment — the two platforms differ:

  • iOS is a real gate against committed baselines (OBJ-1546), but the ios-widget-snapshots job still always uploads its 248 WidgetSnapshotTests PNGs as the CI artifact, pass or fail — useful to eyeball what actually rendered on that run:
    bash
    gh run download <run-id> -n ios-widget-snapshots -D ./evidence
    HomeWidgetAccentedTests' 2 PNGs aren't part of this artifact — pull them from ionic_frontend/ios/App/HomeWidgetExtensionTests/__Snapshots__/HomeWidgetAccentedTests/ in the repo instead, same as an Android golden.
  • Android is a gate against committed goldens, so a green run uploads nothing — the evidence is the golden files already in the repo:
    bash
    cp ionic_frontend/android/app/src/test/snapshots/home-widget-*.png ./evidence
    Only a failing run has anything to download, and it's diagnostic (actual vs. compare), not the golden itself:
    bash
    gh run download <run-id> -n android-widget-snapshots-diff -D ./evidence
bash
multica issue comment add <issue-id> --content-file ./comment.md \
  --attachment ./evidence/home-widget-default--light--small.png \
  --attachment ./evidence/home-widget-default--dark--small.png  # repeat --attachment per PNG

Find <run-id> via gh run list --workflow mobile-android.yml / mobile-ios.yml, or gh pr checks <pr-number>.

Background check-in delivery build setup (v4.22)

Native build-side setup for zero-app-open widget check-in delivery (WorkManager on Android, BGTaskScheduler on iOS). Runtime behavior: Background check-in delivery. Credential lifecycle: Check-in token authentication.

Android — WorkManager

  • Dependency: implementation "androidx.work:work-runtime:$androidxWorkVersion" (ionic_frontend/android/app/build.gradle), version pinned via androidxWorkVersion = '2.10.0' (ionic_frontend/android/variables.gradle).
  • Permissions: none added. androidx.work needs no AndroidManifest.xml permission for basic Worker/PeriodicWorkRequest usage at this app's target SDK — its manifest-merged components (e.g. SystemJobService) come in automatically via the AAR. The existing INTERNET and POST_NOTIFICATIONS permissions predate this feature and are unrelated to it.
  • GraphQL endpoint: GRAPHQL_BASE_URL buildConfigField in build.gradle, sourced from System.getenv("ANDROID_GRAPHQL_BASE_URL") at Gradle build time, defaulting to https://api.objectuve.com/graphql — mirrors the frontend's VITE_API_URL-derived endpoint, since native Gradle builds have no access to Vite env vars. Not currently forwarded by mobile-android.yml or mobile-android-release.yml, so every CI-built Android artifact points at production regardless of build environment.
  • CI: no new job. CheckinDeliveryWorker/WidgetBridgePlugin compile as part of the existing build-android step; delivery logic is covered by JVM-level unit tests (CheckinApiClientTest.java, CheckinDeliveryWorkerTest.java) alongside the rest of the Android suite, plus an on-device instrumented test added for Phase 5 verification (CheckinDeliveryForceStopSurvivalTest.java, under androidTest/) — not wired into CI (needs a device/emulator this workflow doesn't run against), exercised manually against a real emulator instead (see Task 2's on-device verification record in .planning/STATE.md).

iOS — BGTaskScheduler

  • New Info.plist keys (App target, ionic_frontend/ios/App/App/Info.plist): BGTaskSchedulerPermittedIdentifiers = ["com.objectuve.ionic.checkin-delivery-retry"], UIBackgroundModes = ["processing", "fetch"]. No new entitlement or App ID capability was needed — the BGTask handler runs inside the existing App target and reuses the App Group + Keychain access group WidgetBridgePlugin.swift already declares for the base widget.
  • GraphQL endpoint: GRAPHQL_BASE_URL, resolved via ionic_frontend/ios/graphql.xcconfig (GRAPHQL_BASE_URL_PRODUCTION_DEFAULT = https://api.objectuve.com/graphql, overridable per build via GRAPHQL_BASE_URL_OVERRIDE), wired into both the App and HomeWidgetExtension targets' Info.plist as $(GRAPHQL_BASE_URL), read at runtime via Bundle.main.object(forInfoDictionaryKey:). Mirrors Android's env-var-with-production-default pattern; also not yet wired into mobile-ios.yml.
  • Launch wiring: AppDelegate.swift registers the task (CheckinDeliveryTask.register() in didFinishLaunchingWithOptions — required before that method returns, per Apple's BGTaskScheduler contract) and re-arms it on backgrounding (CheckinDeliveryTaskScheduling.schedule() in applicationDidEnterBackground).
  • CI: no new job. CheckinApiResponseTests.swift and CheckinDeliveryTaskTests.swift (both in the HomeWidgetExtensionTests target) run via the existing ios-widget-snapshots job's xcodebuild test -scheme HomeWidgetExtensionTests step — see Widget Snapshot Evidence (CI) above for that job's other responsibilities. These tests exercise only the pure drain/parse logic (no Keychain, no App Group, no live network); CheckinDeliveryTaskScheduling's actual scheduling/Keychain-touching code isn't unit-tested, and there is no CI-side proof the OS actually invokes the registered BGTaskScheduler handler while the app is closed.

Accepted risk, shipped: whether BGTaskScheduler genuinely fires the registered retry handler while the app is fully closed remains unverified — two on-device verification sessions could not complete the live-fire probe (no physical device available; a Simulator/Xcode debug-API disconnect blocked _simulateLaunchForTaskWithIdentifier: even after the scheduling code itself was verified correct through a successful submitTaskRequest). Josh explicitly accepted shipping on this evidence (2026-07-29) rather than blocking on unavailable hardware; real-world reliability is to be monitored via production telemetry post-launch. See Known open item in the widget doc for the full detail and risk framing.

GitHub Secrets

SecretPurpose
APPLE_IDApple Developer account email
APPLE_TEAM_IDApple Developer Team ID (10-char alphanumeric)
ASC_KEY_IDApp Store Connect API Key ID
ASC_ISSUER_IDApp Store Connect API Issuer ID
ASC_KEY_CONTENTApp Store Connect API Key .p8 contents (base64-encoded)
MATCH_GIT_URLPrivate git repo URL for Fastlane Match certificates
MATCH_PASSWORDEncryption passphrase for Match certificate repo
MATCH_GIT_PRIVATE_KEYSSH private key to access the Match certificates repo
ANDROID_KEYSTORE_BASE64Android release keystore (base64-encoded)
ANDROID_KEYSTORE_PASSWORDKeystore password
ANDROID_KEY_ALIASKey alias within the keystore
ANDROID_KEY_PASSWORDKey password
GOOGLE_PLAY_JSON_KEY_BASE64Google Play service account JSON key (base64-encoded)

Troubleshooting

Expired iOS Certificates

If Match fails with certificate expiration errors:

bash
bundle exec fastlane match nuke appstore     # Revoke expired certs
bundle exec fastlane match appstore          # Generate new ones

Android Signing Errors

  • Verify keystore env vars are set: ANDROID_KEYSTORE_PATH, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD
  • For local builds without signing, the debug keystore is used automatically

Capacitor Sync Failures

bash
npx cap sync --inline          # Verbose output
npx cap doctor                 # Diagnose issues

Version Mismatch

If native versions are out of sync with package.json:

bash
npm run sync-version -- --build-number 1

A mobile build dispatched against a v* tag can race that tag's own correction and ship a version label paired with a stale CHANGELOG.md (OBJ-1684)

production.yml has always verified that the tagged commit's ionic_frontend/CHANGELOG.md contains a ## [<version>] entry for the tag being deployed (Verify changelog entry exists for this release, .github/workflows/production.yml:45-58) — a manually-cut tag that skips scripts/release.sh (which adds the changelog header and the version bump in the same commit) fails this check immediately, before any GCP operation runs. None of mobile-android.yml, mobile-ios.yml, or mobile-android-release.yml had the equivalent check, and the gap was live: on 2026-07-22 the v4.2.0 tag was first pushed by hand (chore: bump version to 4.2.0, commit 2557037ea) without a changelog entry, production.yml correctly rejected it, and the tag was corrected ~30 minutes later (chore(release): update CHANGELOG for v4.2.0, commit c992dfa60). In that 30-minute window, both mobile-android.yml and mobile-ios.yml were manually dispatched against ref v4.2.0 — 4-6 seconds after the bad push, long before the correction — and built successfully against the pre-fix commit. VITE_APP_VERSION resolved to the correct 4.2.0 (it comes from the tag/ref name), but the CHANGELOG.md?raw bundled into the app by ChangelogModal.vue's parseChangelog was still frozen at the 4.1.4 entry, because the 4.2.0 section didn't exist in that commit yet. Both shipped: the "What's New" modal showed header v4.2.0 with the newest visible entry stuck at 4.1.4. mobile-android-release.yml (Direct-Install Android Builds) hit the same incident automatically, not manually: it fired at 2026-07-22T22:59:19Z against the bad 2557037ea commit (gh run list --workflow=mobile-android-release.yml, run 29964695879) and completed successfully, meaning the direct-install APK sideload build from that window carried the same version/changelog mismatch.

Fix (shipped, PR #1738): ported production.yml's changelog-presence check into mobile-android.yml/mobile-ios.yml, right after version resolution:

  • mobile-android.yml already had a "Resolve version" step (steps.version.outputs.version, from VITE_APP_VERSION/the ref) — the guard was added immediately after it.
  • mobile-ios.yml had no prior version-resolution step (its "Build Web Assets" step never sets VITE_APP_VERSION; version comes from package.json via sync-version.cjs) — a new "Resolve version" step was added first (version=$(node -e "console.log(require('./package.json').version)")), then the same guard.

Fix (shipped, PR #1739): ported the same guard into mobile-android-release.yml, closing the last gap. Placement differs from the other two: this workflow's "Resolve tag" step (.github/workflows/mobile-android-release.yml:43-53) runs before actions/checkout@v6 (:55-57), so no repo content — and no ionic_frontend/CHANGELOG.md to grep — exists on the runner until after checkout completes. The "Verify changelog entry exists for this release" step (:59-67) is placed immediately after checkout instead, still before any build work (Setup Node, Setup Java, npm ci, web asset build) starts.

All three fail the same way production.yml does: ::error::No changelog entry for v$version found in ionic_frontend/CHANGELOG.md. naming the fix (delete the tag, wait for scripts/release.sh to re-cut it, re-dispatch against the corrected ref).

General rule: any workflow that resolves a version label from a tag/ref name — independent of a content check on what that ref's commit actually contains — needs the same guard production.yml already carries, not only for CHANGELOG.md but for any future release-consistency assertion added to the primary deploy pipeline. A manually dispatched or tag-triggered workflow can always check out a ref before a validation-triggered correction lands on it; the guard is what makes that window harmless instead of silently shipping mismatched content. Where in a workflow's step order the guard belongs depends on when repo content actually becomes available — right after version resolution in mobile-android.yml/mobile-ios.yml, right after checkout in mobile-android-release.yml — not a fixed position.

The same ordering hazard applies to hand-drafted mobile store copy, not just the in-app changelog bundle — see store-release-notes.md §2 for the rule that store copy must be drafted from ionic_frontend/CHANGELOG.md at the tagged commit, never before the release command has landed the changelog rename.

Xcode Signing Errors

If Xcode shows "No signing certificate" or provisioning profile issues:

  1. Run bundle exec fastlane match appstore --readonly to download certs
  2. In Xcode: Signing & Capabilities > uncheck "Automatically manage signing"
  3. Select the Match-generated provisioning profile

CODE_SIGN_ENTITLEMENTS must be wired into every signed target, not just present on disk

Adding an .entitlements file to a target's directory does nothing on its own — Xcode only applies it when the target's build settings set CODE_SIGN_ENTITLEMENTS to that file's path. The main app's App.entitlements (aps-environment, associated-domains) existed in the repo for some time before v4.1 Phase 4 without ever being wired into the App target's Debug/Release build settings — the App Group capability added for home-screen widgets would have been silently inert if this hadn't been caught in code review. Any new capability added to an entitlements file needs a matching CODE_SIGN_ENTITLEMENTS = <Target>/<Target>.entitlements line in project.pbxproj for both Debug and Release configs of the target that should carry it. A new extension target (like HomeWidgetExtension) needs its own entitlements file wired the same way — it does not inherit the main app's.

Re-recording swift-snapshot-testing baselines: record mode fails every assertion by design, and must be recorded on the CI runner, not a local Mac

Before OBJ-1546, WidgetSnapshotTests.swift and HomeWidgetAccentedTests.swift both forced SnapshotTesting.isRecording = true permanently in setUp(), because no baseline PNGs were committed — a fresh clone never has a __Snapshots__/ baseline to diff against, so the default (non-recording) mode would fail every run forever (see upstream verifySnapshot's .missing record-mode behavior). Even with recording forced on, assertSnapshot still reports each recorded assertion as a failing XCTest — that's intentional upstream behavior meant to stop you from accidentally leaving record mode on in a real regression-gate run, not a bug in this harness. That's why the CI job needed continue-on-error: true on the test-run step, gated instead on an independent PNG-count check as the real pass/fail signal.

As of OBJ-1546, both files have isRecording = false (the default); as of OBJ-1595 (Phase 6, WIDGET-COVERAGE-1) 250 baseline PNGs are committed under __Snapshots__/{WidgetSnapshotTests,HomeWidgetAccentedTests}/xcodebuild test -scheme HomeWidgetExtensionTests now genuinely exits non-zero on a real pixel mismatch and zero on a clean pass, matching Roborazzi's Android behavior (verifyRoborazziDebug) for the first time. The record-mode quirk above only matters again when you're intentionally re-recording baselines — normal CI runs no longer hit it.

Updating baselines after an intentional widget change:

  1. Delete the stale PNG(s) under __Snapshots__/WidgetSnapshotTests/ or __Snapshots__/HomeWidgetAccentedTests/ for the states you changed (or the whole directory to re-record everything).
  2. Temporarily flip isRecording = true back on in the relevant test file's setUp().
  3. Run xcodebuild test -scheme HomeWidgetExtensionTests -destination 'platform=iOS Simulator,name=iPhone 17 Pro' to write the new PNGs — the run reports failures per the record-mode quirk above, that's expected; ignore the exit code and check that the PNGs landed.
  4. Flip isRecording back to false, review the diff, and commit the updated baselines.

Record on the same runner image the gate runs against — never a local Mac. This isn't a style preference: OBJ-1546's first fix attempt recorded all 30 baselines locally (Xcode 26.5 / iPhone 17 Pro simulator) and CI failed identically across two consecutive runs, because sub-point Xcode/OS rendering differences between a local Mac and the macos-26 GitHub Actions runner produce different pixels for the same view — same dimensions, different anti-aliasing/font rendering, enough to fail swift-snapshot-testing's exact-pixel comparison. The fix was to record directly on the CI runner instead: temporarily flip isRecording = true in a CI-only change, add a throwaway artifact-upload step for the test file(s) being re-recorded, push, watch the run, download the artifact via gh run download, replace the committed baselines with the CI-recorded PNGs, then flip isRecording back to false and remove the throwaway upload step. If a snapshot harness on either platform ever needs re-recording again, record it this way — never on a laptop.

Widget snapshot legibility gate failure

Verify widget snapshot legibility (both the iOS and Android widget-snapshot jobs, and the capture-ui-evidence job on preview.yml) runs scripts/check-png-legibility.mjs and fails on a PNG that is blank or near-invisible against the backdrop its filename implies. It exists because the ui-evidence-and-critique legibility step was prose-only and was repeatedly recited without being performed — blank baselines shipped twice and were caught by a human, not the gate.

Reproduce locally with the same command CI runs:

bash
node scripts/check-png-legibility.mjs ionic_frontend/ios/App/HomeWidgetExtensionTests/__Snapshots__/
node scripts/check-png-legibility.mjs ionic_frontend/android/app/src/test/snapshots/

The error names the inferred backdrop and both metrics. Two legitimate fixes:

  1. The asset really is single-theme but its filename says otherwise — add the missing --light-- / --dark-- (or -light- / -dark-) token per the filename convention and commit the correct per-theme variant. A filename with no theme token is evaluated against both backdrops and must be legible in both.
  2. It is a genuine single-appearance surface (a WidgetRenderingMode.vibrant Lock Screen accessory has no light/dark variant) — extend SINGLE_APPEARANCE_PATTERNS in scripts/check-png-legibility.mjs. See the vibrant carve-out in multica-workspace-skills.md § 9.

Do not loosen --threshold-ink125 / --threshold-ink40 to make the gate pass. That reproduces exactly the failure this check was built to stop. If a real asset is legitimately failing, it is a filename/carve-out problem, not a threshold problem.

swift-snapshot-testing's automatic filename sanitizer silently collapses -- separators

swift-snapshot-testing's named: parameter is passed through its internal sanitizePathComponent(), which runs string.replacingOccurrences(of: "\\W+", with: "-", regularExpression: true) — this collapses any run of one or more non-word characters (including intentional -- separators) down to a single -, and the library prefixes the test method name to the result. This means no named: string, however written, can ever produce a literal -- through assertSnapshot's automatic file naming — e.g. a named: value of "home-widget-all-done--dark--medium" is written to disk as testAllWidgetStates.home-widget-all-done-dark-medium.png, not the intended home-widget-all-done--dark--medium.png. This is unfixable at the Swift call-site without abandoning assertSnapshot's automatic file management entirely (a much larger change). The ios-widget-snapshots CI job works around it with a post-hoc "Normalize widget snapshot filenames" step that reconstructs the <surface>--<mode>--<size>.png convention from the fixed, known vocabulary (mode ∈ {light, dark}, size ∈ {small, medium} — neither contains a dash, so reconstruction is deterministic). If you add a new assertSnapshot(..., named:) call for another native surface, don't expect a literal -- to survive — add a matching CI-side normalize step, not a Swift-side fix. (Discovered during OBJ-1200's CI wiring — see PR #1334.)

swift-snapshot-testing's UIHostingController-based rendering crashes on iOS 26.x simulators — render via ImageRenderer instead

swift-snapshot-testing 1.19.2's .image snapshot strategies for View/UIViewController go through UIHostingController, which crashes on iOS 26.x simulators (pointfreeco/swift-snapshot-testing#1089, open, unfixed upstream as of this writing). WidgetSnapshotTests.swift avoids the crashing code path entirely by rendering each SwiftUI widget view to a UIImage via SwiftUI's own ImageRenderer first, then snapshotting the plain UIImage (not the View directly). If you're adding a new SwiftUI snapshot test in this repo, follow the same ImageRenderer-first pattern — snapshotting a View/UIViewController directly will reintroduce this crash on the CI runner's iOS 26.x simulators.

Android native widget/plugin code is Java, not Kotlin

There is no existing Kotlin toolchain in this repo. Android-side production native code (the HomeWidgetProvider, WidgetBridgePlugin, WidgetSnapshot under ionic_frontend/android/app/src/main/java/com/objectuve/ionic/) is written in Java, matching the existing native plugin code it builds on. A contributor expecting Jetpack Compose/Glance for the widget should note this is deliberate — RemoteViews (zero-dependency, synchronous onUpdate) was chosen over Glance specifically to avoid pulling the Compose runtime into the app for a read-only V1 widget. See PLAN.md for the full rationale. (v4.1 Phase 4's widget snapshot testWidgetSnapshotTest.kt — is written in Kotlin via AGP 9's built-in Kotlin support; see the AGP 9 gotcha below. That's test-only Kotlin, not a change to the Java-only production stance.)

AGP 9's built-in Kotlin support — do not apply org.jetbrains.kotlin.android

ionic_frontend/android pins AGP 9.1.0, which ships built-in Kotlin support enabled by default. Explicitly applying org.jetbrains.kotlin.android (the pre-AGP-9 convention, and what most library sample projects — including Roborazzi's own — still show) makes AGP refuse to configure, with an explicit deprecation error: "The 'org.jetbrains.kotlin.android' plugin is no longer required for Kotlin support since AGP 9.0... Remove the 'org.jetbrains.kotlin.android' plugin from this project's build file." Kotlin source under app/src/test/ (e.g. WidgetSnapshotTest.kt, added for the widget snapshot harness) compiles via AGP's own built-in compiler — verifiable by the build-output path containing built_in_kotlinc/ rather than an externally-applied Kotlin Gradle Plugin task. The root build.gradle's classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:...' buildscript entry is vestigial under this setup (AGP 9's built-in Kotlin carries its own KGP runtime dependency) but harmless to leave — it just doesn't do anything anymore. If you're adding a new Kotlin file to ionic_frontend/android, do not add a Kotlin plugin apply line; AGP already handles it. (Discovered during OBJ-1200's Android widget-snapshot harness — see PR #1329.)

Android widget colors — a dedicated file, on purpose

ionic_frontend/android/app/src/main/res/values/colors.xml exists on disk in a local Capacitor build workdir (colorPrimary/colorPrimaryDark/colorAccent) but is not tracked in git — it's a Capacitor-template artifact regenerated locally, not part of the repo's committed history. Writing widget-specific colors into it risks silently clobbering it depending on how/when it gets regenerated in a given environment. The widget's six native color-token values live in a dedicated file instead — res/values/colors_widget.xml + res/values-night/colors_widget.xml — with no collision risk. If you're adding new native Android colors for any feature, prefer a dedicated colors_<feature>.xml over touching colors.xml directly.

RemoteViews rejects bare <View> elements — use <FrameLayout> for decorative shapes

RemoteViews only supports a fixed allowlist of view classes (FrameLayout, LinearLayout, TextView, ImageView, etc.) — plain android.view.View is not on it, and inflating a layout containing one throws InflateException: Class not allowed to be inflated on real devices, not just under test. home_widget_small.xml and home_widget_medium.xml (added in PR #1321) originally used bare <View> elements for a decorative circle and two divider lines; this was a live, shipped crash risk on the widget's header divider and first-run glyph. Building the OBJ-1200 snapshot harness surfaced it (Roborazzi's Robolectric-backed inflation enforces the same allowlist as a real device), and it was fixed in PR #1329 by swapping all 3 occurrences to <FrameLayout> with identical layout_width/layout_height/margin/background attributes — zero visual or behavioral difference, just an inflatable container. If you're adding a new decorative shape to a RemoteViews-based layout, reach for <FrameLayout> (or another allowlisted view), never a bare <View>.

A bare <View> in a RemoteViews layout crashes on real-device placement, not just in a debug harness

RemoteViews — the mechanism AppWidgetHost/the Launcher uses to render home-screen widgets out-of-process — only supports a hardcoded allow-list of View subclasses (FrameLayout, LinearLayout, RelativeLayout, TextView, ImageView, Button, etc.). A bare android.view.View (e.g. used as a solid-color divider or spacer, with no src) is not on that allow-list, and inflating one throws InflateException: Class not allowed to be inflated android.view.View — deterministically, on every placement, not a flake.

This first surfaced as an unconfirmed finding from a debug-harness RemoteViews.apply() call during v4.2 Phase 2 review (a pre-existing divider in home_widget_medium.xml, predating that phase). It was confirmed as a real, 100%-reproducible crash during manual device QA on a real emulator/Launcher: a state_first_run glyph placeholder in home_widget_small.xml — the very first thing a fresh install shows on placement, before any snapshot has ever synced — used a bare <View> and crashed every fresh-install placement. Two more instances of the same pattern were found in home_widget_medium.xml (an unconditional column divider, and a footer_divider that's visibility="gone" by default but crashes whenever code sets it visible).

Fix: replace the bare <View> with a <FrameLayout> — drop src, keep android:background, add android:contentDescription="@null". FrameLayout is on the RemoteViews allow-list and renders identically for a solid-color divider/spacer/glyph placeholder. (This phase's original fix used <ImageView> for these same 3 sites; the OBJ-1379 master-integration merge standardized on <FrameLayout> — the choice PR #1329's harness-validated fix above already established on master — so all RemoteViews bare-<View> replacements in this codebase now use one consistent pattern.)

Why this slipped through: aapt2 compile and javac/swiftc-style type-checks validate that the XML is well-formed and that resource IDs resolve — they don't know about RemoteViews' runtime inflation allow-list, which is enforced only when a real AppWidgetHost (or a direct RemoteViews.apply() call) inflates the layout. A debug harness that calls RemoteViews.apply() directly reproduces this; aapt2/build-only verification does not. If you're adding any new element to a home_widget_*.xml layout, use only allow-listed view classes, and verify with a real inflation path (harness or device), not just a clean build.

2026-07-21 (v4.11 Android parity): the kindling refinement and Large-equivalent breakpoint (PRs #1673/#1684) introduced zero new view classes — home_widget_small.xml, home_widget_medium.xml, and home_widget_large.xml use only FrameLayout, LinearLayout, TextView, and ImageView, every one already on the allow-list above; the phase-3 motivator glyph (widget_motivator_glyph_chevron, home_widget_large.xml) is a TextView, and the motivator icon slot is an ImageView. Nothing to extend here.

Android adaptive icon regressions: scale error + missing monochrome layer (OBJ-2296)

Every Android launcher-icon regeneration to date has broken the icon in production, twice, for two different reasons — this is now the second full regen in the mark's history to ship broken (the first was bfe1dae0a, "Regenerate app icons with rotated logo geometry"). Neither failure has a CI signal: no workflow in .github/workflows/mobile-android*.yml greps for icon/mipmap/launcher/adaptive, and the app-icon checklist item above was, until this entry, a single unchecked line with no callout for either trap.

v4.4.5 root cause (primary, theme-independent): commit 75b0bf57d (the v12 logo rebrand, PR #2139) regenerated every mipmap-*/ic_launcher_foreground.png at the legacy 48dp size table instead of the adaptive 108dp table, across all five densities (mdpi 48px, hdpi 72px, xhdpi 96px, xxhdpi 144px, xxxhdpi 192px — each exactly what the pre-regen bfe1dae0a assets shipped at before the adaptive-icon format existed). Android stretches whatever foreground bitmap it's given to fill the 108dp canvas regardless of intrinsic size, so the mark rendered 2.25× too large and the 72dp adaptive mask cropped it — the ring survived only as a thin rim and the gold dot was cropped to a sliver. This broke the launcher icon for every Android 8+ user, themed icons on or off — a wider blast radius than an initial diagnosis assumed, because the visual symptom (a wallpaper-tinted, dot-less notch) looked like a themed-icon-only bug and wasn't.

Secondary, still-real gap: neither mipmap-anydpi-v26/ic_launcher.xml nor ic_launcher_round.xml has ever declared a <monochrome> layer, so Android 13+'s Material You "Themed icons" feature has always had to auto-synthesize a themed icon from the foreground alone — routinely producing a low-fidelity, wallpaper-tinted result even once the scale bug above is fixed.

Fix (shipped, PR #2194): replaced the five mis-scaled PNGs with a single vector res/drawable/ic_launcher_foreground.xml, sized to the 108dp canvas via an explicit <group pivotX/pivotY/scaleX/scaleY/translateX/translateY> mapping — a single scale number now controls size, instead of five hand-exported bitmaps that can silently drift out of the correct table again. Added the repo's first <monochrome> layer (res/drawable/ic_launcher_monochrome.xml, ring and dot kept as separate elements — contiguity is not an Android adaptive-icon requirement). Also deleted two leftover Android Studio stock-template files (res/drawable-v24/ic_launcher_foreground.xml, the stock robot; res/drawable/ic_launcher_background.xml, the stock teal grid) that would otherwise have outranked the real assets at minSdkVersion 24.

Still open as of this writing: on-device / Android Studio "Preview Themed Icon" verification (both themed icons on and off) could not be run in the implementing sandbox (no Android SDK) — required before/at the next Play Store rollout. A Roborazzi regression guard (android/app/src/test/java/com/objectuve/ionic/WidgetSnapshotTest.kt sibling pinning goldens for R.mipmap.ic_launcher as an AdaptiveIconDrawable + its getMonochrome() layer, JVM-only, no emulator needed) would have caught the scale bug at the commit that introduced it and is proposed but unimplemented — needs Android SDK access to generate the initial goldens.

If you're regenerating this icon again: verify the new ic_launcher_foreground asset against the 108dp table (not 48dp), confirm both mipmap-anydpi-v26/*.xml files carry a <monochrome> layer, and run the Android Studio themed-icon preview before merging — see the app-icon checklist above.

First App Store Submission

Before the first submission, prepare:

  • App screenshots for required device sizes
  • App description, keywords, and privacy policy URL
  • App icon (1024x1024 for App Store, adaptive icon for Play Store) — regenerating the Android adaptive icon must confirm each mipmap-*/ic_launcher_foreground asset is sized to the 108dp adaptive canvas (not the legacy 48dp table) and that both mipmap-anydpi-v26/ic_launcher.xml / ic_launcher_round.xml declare a <monochrome> layer, then pass an Android Studio Image Asset Studio "Preview Themed Icon" check (themed icons on and off) before shipping — see "Android adaptive icon regressions" under Troubleshooting below
  • Privacy policy and terms of service URLs

Shared-runner gotchas (mobile emulation)

Environment traps on the shared Multica runner Mac, discovered during OBJ-1148 (Phase 4 home-screen widgets). All three are runner-environment reference — the evidence protocol itself is owned by the ui-evidence-and-critique workspace skill (multica-workspace-skills.md).

Gradle needs JDK 21 (runner default is JDK 17)

Android Gradle builds on the shared Multica runner Mac fail under the default JDK 17. The runner has Intel Homebrew at /usr/local; pass the JDK explicitly per-invocation (discovered by Codi on OBJ-1148):

bash
./gradlew :app:assembleDebug -Dorg.gradle.java.home=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home

Stale CoreSimulatorService (Xcode 26.5): xcrun simctl hangs

Symptom: xcrun simctl hangs indefinitely; the underlying error is a dlopen symbol failure in IDESimulatorFoundation. Classic stale CoreSimulatorService / framework-version-mismatch state. Fix sequence:

bash
sudo launchctl remove com.apple.CoreSimulator.CoreSimulatorService
# or: killall -9 com.apple.CoreSimulator.CoreSimulatorService
xcodebuild -runFirstLaunch

Then update Xcode if the mismatch persists.

Native UI evidence: deterministic snapshots, never launcher interaction

Native UI evidence (WidgetKit / App Widget views, native screens) uses deterministic snapshot rendering — Roborazzi (Android, JVM via Robolectric, no emulator) or swift-snapshot-testing (iOS via xcodebuild test) — or direct screencap (adb exec-out screencap -p, xcrun simctl io booted screenshot) as a fallback when no snapshot harness exists yet for a given surface. Never drive an emulator's launcher/home-screen UI (widget pickers, placement flows): under constrained CPU on a headless runner they ANR permanently — an environmental failure, not evidence. See the ui-evidence-and-critique skill in multica-workspace-skills.md for the full evidence protocol.

As of v4.1 Phase 4 (OBJ-1200), a PR-triggered CI snapshot harness exists for the home-screen widget surface — see Widget Snapshot Evidence (CI) above. This is the "preferred" path the ui-evidence-and-critique skill describes; pull the PNGs with gh run download <run-id> rather than attempting local emulator/simulator capture. Note the tool name: the skill mirror in multica-workspace-skills.md still references Paparazzi as the Android tool — the actual implementation uses Roborazzi, because Paparazzi's latest release (2.0.0-alpha04 at the time) only supports AGP up to 8.13.2, while this repo pins AGP 9.1.0 (Roborazzi has shipped AGP 9 support since ~1.61). If you're wiring a snapshot harness for a new native surface, default to Roborazzi on Android unless Paparazzi has since shipped AGP 9 support — check before assuming the skill text is current.

Progressive Web Application (PWA) & Capacitor

The native builds skip PWA service worker registration entirely. When the frontend build outputs dist/sw.js and dist/manifest.webmanifest (generated by vite-plugin-pwa at build time), the Capacitor iOS and Android builds do NOT include or activate these artifacts.

Why: The service worker is a web-platform feature. Native apps have their own app lifecycle (Capacitor orchestrates native storage, push notifications, offline behavior, etc.). Running a service worker inside the Capacitor WebView would create duplicate offline/caching logic and complicate the native runtime.

Technical gate: The service worker is registered in src/lib/swInit.ts with an explicit platform check:

typescript
export function initServiceWorker(): void {
  if (!Capacitor.isNativePlatform()) {
    const { register } = useServiceWorker()
    register()
  }
}

The same idiom is used in usePushNotifications.ts — all web-only APIs are gated this way.

Impact: PWA artifacts in dist/ are safe to ignore during mobile builds. The Capacitor copy step does not deploy them to the iOS .app or Android .apk.

For PWA architecture and offline details, see docs/architecture/pwa-augment.md.

Last updated: 2026-09-08 (OBJ-3535: promote-android now auto-promotes to production on every v* tag push, pinned to the exact versionCode just built — Promoting to production rewritten around that as the primary path, manual workflow_dispatch --track=production now documented as the escape hatch only)

Loading…