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
- Log into developer.apple.com and renew membership if expired ($99/yr)
- Verify app ID
com.objectuve.ionicis registered under Certificates, IDs & Profiles > Identifiers - Create an app entry in App Store Connect if one doesn't exist
- 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
.p8file (only downloadable once) - Note the Key ID and Issuer ID
Google Play
- Log into Google Play Console
- Create the app listing for
com.objectuve.ionicif it doesn't exist - 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):
- Create a private git repo for certificate storage (e.g.,
github.com/joshlock3/enkidu-certificates) - Run initial Match setup from
ionic_frontend/:bashbundle install MATCH_GIT_URL=git@github.com:joshlock3/enkidu-certificates.git \ APPLE_ID=josh@objectuve.com \ bundle exec fastlane match appstore - This generates and stores App Store distribution certificates and provisioning profiles
- For development certificates:
bundle exec fastlane match development - Team members download existing certs with:
bundle exec fastlane match appstore --readonly
Android:
- Generate a release keystore (one-time):bash
keytool -genkey -v -keystore release.keystore -alias objectuve \ -keyalg RSA -keysize 2048 -validity 10000 - Store the keystore securely — it cannot be regenerated. If lost, the app must be re-published under a new signing key.
- 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 21 —
brew install openjdk@21(matches CI'stemurin/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@v6config)
First Time Setup
cd ionic_frontend
npm ci
gem install bundler
bundle install
npm run build:mobileRunning Locally
iOS:
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 simulatorAndroid:
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 emulatorHot Reload (Development)
For faster iteration during development:
npm run dev # Start Vite dev server
# In another terminal:
npx cap run ios --livereload --external
# or
npx cap run android --livereload --externalVersion 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:
npm run sync-version # Auto build number
npm run sync-version -- --build-number 42 # Explicit build numberThe script updates:
- iOS:
MARKETING_VERSIONandCURRENT_PROJECT_VERSIONinproject.pbxproj - Android:
versionNameandversionCodeinbuild.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.jsonwhen a branch is first pushed - Increment the version on every direct push to
master
CI/CD Pipeline
Workflows
| Workflow | Trigger | Runner | File |
|---|---|---|---|
| Android Build & Deploy | Push of v* tag + manual | ubuntu-latest | mobile-android.yml |
| Android Direct-Install Release Build | Push of v* tag + manual | ubuntu-latest | mobile-android-release.yml |
android-widget-snapshots (Widget Snapshot Regression Gate) | PR touching ionic_frontend/android/** | ubuntu-latest | mobile-android.yml |
Android Release Build & Boot Smoke (proguard-keep-rules-guard + boot-smoke) | PR touching ionic_frontend/android/** (branches master, gsd/*) | ubuntu-latest | mobile-android-boot-smoke.yml |
| iOS Build & Deploy | Manual only | macos-26 | mobile-ios.yml |
ios-signing-check (Verify iOS signing assets) | PR touching ionic_frontend/ios/** or ionic_frontend/fastlane/** | macos-26 | mobile-ios.yml |
ios-widget-extension-build (Build HomeWidgetExtension Target) | PR touching ionic_frontend/ios/** | macos-26 | mobile-ios.yml |
ios-widget-snapshots (Widget Snapshot Tests) | PR touching ionic_frontend/ios/** | macos-26 | mobile-ios.yml |
iOS Release Build & Boot Smoke (boot-smoke + plugin-registration-check) | PR touching ionic_frontend/ios/** (branches master, gsd/*) | macos-26 | mobile-ios-boot-smoke.yml |
| Mobile Viewport Smoke Tests | After production deploy + manual | ubuntu-latest | playwright-smoke.yml |
Capacitor Sync Drift (check) | PR touching package.json/package-lock.json/android/**/ios/** + push to master | ubuntu-latest | cap-sync-drift.yml |
Android Pipeline
Runs automatically on push of a v* tag (e.g. v3.12.15):
- Build web assets with production env vars
cap sync android- Sync version from
package.json(versionName); versionCode computed from seconds-since-2024-01-01 - Build release AAB with signing
- Upload to Google Play internal testing track (
fastlane android beta) - Once that upload succeeds,
promote-androidauto-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.MessageHandlerdoesn't extendcom.getcapacitor.Plugin, so it falls outside Capacitor's ownconsumerProguardFileskeep rules.WidgetBridgePlugin/HomeWidgetProvider— defense-in-depth on top of existing AGP/Capacitor default coverage (the@CapacitorPlugin/manifest-registeredBroadcastReceiverkeep rules already cover these; this is a belt-and-suspenders explicit-keep).com.google.firebase.messaging.**andcom.revenuecat.purchases.**— defensive-keep/-dontwarnfor 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*):
- Build web assets with production env vars (matches
production.yml) cap sync androidand sync version from the tag (--version ${TAG#v})- Build a signed universal APK with the upload keystore (
./gradlew :app:assembleRelease) - Upload to
gs://enkidu-mobile-builds/android/objectuve-vX.Y.Z-<run>.apkvia WIF - Prune the bucket to the most recent 10 APKs
- 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) - Wait for
production.ymlto 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:
./gcp_setup/04_create_mobile_builds_bucket.shCreates 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):
- Build web assets with production env vars
cap sync ios- Sync version from
package.json - Fetch signing certs via Fastlane Match
- Build signed IPA
- 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-only — bundle 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 appstorerun. 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:
xcodebuild build \
-project App.xcodeproj \
-target HomeWidgetExtension \
-destination 'generic/platform=iOS Simulator' \
CODE_SIGNING_ALLOWED=NONo 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.gradleionic_frontend/android/app/capacitor.build.gradleionic_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:
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 commitCommit 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 grepsproguard-rules.profor 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: noANDROID_KEYSTORE_PATHis set, so signing falls back to the debug config, butminifyEnabled/shrinkResourcesstay on regardless). Verifies an R8mapping.txtwas actually produced (proof minification ran), then installs the APK on areactivecircus/android-emulator-runneremulator (API 34, Pixel 6) and runsandroid/scripts/boot-smoke-check.sh: launchMainActivity, wait 10s, assert the process is still alive and logcat shows noFATAL EXCEPTION/ANR.
Satisfy it locally:
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 upDon'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:
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=NOIf 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:
- Staging is not guaranteed to stay a faithful rehearsal of production auth.
scripts/clerk-auth-config.expected.json'sstaging_accepted_divergenceslist 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 andpasskeysettings identical on both instances, andsign_in.second_factor.requiredisfalseon both (OBJ-2535/OBJ-2638). Staging is held to full parity with production on every pathscripts/check-clerk-auth-config.mjscompares. 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. - iOS Password AutoFill and a full tap-to-dashboard recording are still not covered.
ios/scripts/boot-smoke-check.shdrives 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 whilesimctl io recordVideocaptures — 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/**):
- Build web assets +
cap sync android(same steps asbuild-android, minus signing/upload). ./gradlew :app:verifyRoborazziDebug— rendershome_widget_small.xml/home_widget_medium.xmlvia Roborazzi (Robolectric on the JVM, no emulator) and diffs every render against the goldens committed ationic_frontend/android/app/src/test/snapshots/, failing the build on any pixel difference beyond a 2% per-image tolerance (WidgetSnapshotTest.kt'sROBORAZZI_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).- On a mismatch, uploads the
_actual/_comparediagnostic PNGs (app/build/outputs/roborazzi/*.png) as theandroid-widget-snapshots-diffartifact,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):
npm ciinionic_frontend— required even though the test target has no Capacitor dependency, becauseApp.xcodeprojresolves several Capacitor plugins as local SPM packages underionic_frontend/node_modules/; without it, package resolution fails before the test target ever builds.xcodebuild test -scheme HomeWidgetExtensionTests— runs bothWidgetSnapshotTests(248 assertions across five state matrices: 8 legacy states xSmallWidgetView/MediumWidgetView/LargeWidgetViewx light/dark, OBJ-1367/OBJ-1520; 15 kindling states — the same sixHomeWidgetStatecases crossed withStreakReactiveStatexStreakMetaphorwhere 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/.accessoryInlinex 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) andHomeWidgetAccentedTests(2 assertions:habit-pip-accented-{done,not-done}, the.accenteda11y-degradation gate) via swift-snapshot-testing, diffing against the 250 baseline PNGs committed under__Snapshots__/{WidgetSnapshotTests,HomeWidgetAccentedTests}/. As of OBJ-1546,isRecordingisfalse(the default) in both files andxcodebuild'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.- Two independent PNG-count checks —
Verify widget snapshot evidence(expects 248, computed from the five matrices' state/family/mode counts — seemobile-ios.yml's own inline comments, which are the source of truth for the current numbers) andVerify accented widget snapshot evidence(expects 2) — are secondary sanity guards layered on top of the realxcodebuildgate: 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. - A filename-normalization step reconstructs the
<surface>--<mode>--<size>.pngconvention for the size-basedtestAllWidgetStatesPNGs only (see thesanitizePathComponentgotcha below) —testAllWidgetStates_kindlingEnabled,testLargeOverflow_motivatorNotClipped,testAccessoryStates, andtestAccessoryStates_flagOffPNGs keep their rawassertSnapshotfilenames (test-method-prefixed, single dashes), matching what's actually committed under__Snapshots__/WidgetSnapshotTests/for those four matrices.HomeWidgetAccentedTests' PNGs keep their rawassertSnapshotfilenames too — they're verified in place, not normalized or uploaded. - A legibility gate —
Verify widget snapshot legibility, runningscripts/check-png-legibility.mjsover__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/lighttoken) and fails one with effectively no ink there. The Android job has the same step afterverifyRoborazziDebug. See Widget snapshot legibility gate failure for what to do when it fires. - Uploads the 248
WidgetSnapshotTestsPNGs as theios-widget-snapshotsartifact,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-snapshotsjob still always uploads its 248WidgetSnapshotTestsPNGs as the CI artifact, pass or fail — useful to eyeball what actually rendered on that run:bashgh run download <run-id> -n ios-widget-snapshots -D ./evidenceHomeWidgetAccentedTests' 2 PNGs aren't part of this artifact — pull them fromionic_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:bashOnly a failing run has anything to download, and it's diagnostic (actual vs. compare), not the golden itself:
cp ionic_frontend/android/app/src/test/snapshots/home-widget-*.png ./evidencebashgh run download <run-id> -n android-widget-snapshots-diff -D ./evidence
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 PNGFind <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,
BGTaskScheduleron 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 viaandroidxWorkVersion = '2.10.0'(ionic_frontend/android/variables.gradle). - Permissions: none added.
androidx.workneeds noAndroidManifest.xmlpermission for basicWorker/PeriodicWorkRequestusage at this app's target SDK — its manifest-merged components (e.g.SystemJobService) come in automatically via the AAR. The existingINTERNETandPOST_NOTIFICATIONSpermissions predate this feature and are unrelated to it. - GraphQL endpoint:
GRAPHQL_BASE_URLbuildConfigFieldinbuild.gradle, sourced fromSystem.getenv("ANDROID_GRAPHQL_BASE_URL")at Gradle build time, defaulting tohttps://api.objectuve.com/graphql— mirrors the frontend'sVITE_API_URL-derived endpoint, since native Gradle builds have no access to Vite env vars. Not currently forwarded bymobile-android.ymlormobile-android-release.yml, so every CI-built Android artifact points at production regardless of build environment. - CI: no new job.
CheckinDeliveryWorker/WidgetBridgePlugincompile as part of the existingbuild-androidstep; 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, underandroidTest/) — 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.plistkeys (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 groupWidgetBridgePlugin.swiftalready declares for the base widget. - GraphQL endpoint:
GRAPHQL_BASE_URL, resolved viaionic_frontend/ios/graphql.xcconfig(GRAPHQL_BASE_URL_PRODUCTION_DEFAULT = https://api.objectuve.com/graphql, overridable per build viaGRAPHQL_BASE_URL_OVERRIDE), wired into both the App andHomeWidgetExtensiontargets'Info.plistas$(GRAPHQL_BASE_URL), read at runtime viaBundle.main.object(forInfoDictionaryKey:). Mirrors Android's env-var-with-production-default pattern; also not yet wired intomobile-ios.yml. - Launch wiring:
AppDelegate.swiftregisters the task (CheckinDeliveryTask.register()indidFinishLaunchingWithOptions— required before that method returns, per Apple'sBGTaskSchedulercontract) and re-arms it on backgrounding (CheckinDeliveryTaskScheduling.schedule()inapplicationDidEnterBackground). - CI: no new job.
CheckinApiResponseTests.swiftandCheckinDeliveryTaskTests.swift(both in theHomeWidgetExtensionTeststarget) run via the existingios-widget-snapshotsjob'sxcodebuild test -scheme HomeWidgetExtensionTestsstep — 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 registeredBGTaskSchedulerhandler 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
| Secret | Purpose |
|---|---|
APPLE_ID | Apple Developer account email |
APPLE_TEAM_ID | Apple Developer Team ID (10-char alphanumeric) |
ASC_KEY_ID | App Store Connect API Key ID |
ASC_ISSUER_ID | App Store Connect API Issuer ID |
ASC_KEY_CONTENT | App Store Connect API Key .p8 contents (base64-encoded) |
MATCH_GIT_URL | Private git repo URL for Fastlane Match certificates |
MATCH_PASSWORD | Encryption passphrase for Match certificate repo |
MATCH_GIT_PRIVATE_KEY | SSH private key to access the Match certificates repo |
ANDROID_KEYSTORE_BASE64 | Android release keystore (base64-encoded) |
ANDROID_KEYSTORE_PASSWORD | Keystore password |
ANDROID_KEY_ALIAS | Key alias within the keystore |
ANDROID_KEY_PASSWORD | Key password |
GOOGLE_PLAY_JSON_KEY_BASE64 | Google Play service account JSON key (base64-encoded) |
Troubleshooting
Expired iOS Certificates
If Match fails with certificate expiration errors:
bundle exec fastlane match nuke appstore # Revoke expired certs
bundle exec fastlane match appstore # Generate new onesAndroid 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
npx cap sync --inline # Verbose output
npx cap doctor # Diagnose issuesVersion Mismatch
If native versions are out of sync with package.json:
npm run sync-version -- --build-number 1A 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.ymlalready had a "Resolve version" step (steps.version.outputs.version, fromVITE_APP_VERSION/the ref) — the guard was added immediately after it.mobile-ios.ymlhad no prior version-resolution step (its "Build Web Assets" step never setsVITE_APP_VERSION; version comes frompackage.jsonviasync-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:
- Run
bundle exec fastlane match appstore --readonlyto download certs - In Xcode: Signing & Capabilities > uncheck "Automatically manage signing"
- 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:
- Delete the stale PNG(s) under
__Snapshots__/WidgetSnapshotTests/or__Snapshots__/HomeWidgetAccentedTests/for the states you changed (or the whole directory to re-record everything). - Temporarily flip
isRecording = trueback on in the relevant test file'ssetUp(). - 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. - Flip
isRecordingback tofalse, 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:
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:
- 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. - It is a genuine single-appearance surface (a
WidgetRenderingMode.vibrantLock Screen accessory has no light/dark variant) — extendSINGLE_APPEARANCE_PATTERNSinscripts/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 test — WidgetSnapshotTest.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_foregroundasset is sized to the 108dp adaptive canvas (not the legacy 48dp table) and that bothmipmap-anydpi-v26/ic_launcher.xml/ic_launcher_round.xmldeclare 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):
./gradlew :app:assembleDebug -Dorg.gradle.java.home=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/HomeStale 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:
sudo launchctl remove com.apple.CoreSimulator.CoreSimulatorService
# or: killall -9 com.apple.CoreSimulator.CoreSimulatorService
xcodebuild -runFirstLaunchThen 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:
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)