Product flavors forced a symmetric frame onto two distributions that are
not peers: FOSS is the universal, secret-free base build (buildable by
anyone / F-Droid) and Google is a credentialed superset that requires our
private google-services.json. The flavor framing made the unsafe build the
default (bare `assemble` built both, google-services.json was required by
default) and needed task-name sniffing to re-derive "is this credentialed?".
Invert to a flag: a bare build is FOSS; `-Pgoogle` opts into Firebase.
- Drop flavorDimensions/productFlavors. Select the distribution sources by
flag via sourceSets["main"]/["test"].java.srcDir(...). The src/{google,foss}
and src/test{Google,Foss} dirs are unchanged — just no longer flavor sets.
- Overlay the distribution manifest (FcmReceiver vs UnifiedPushReceiver) by
flag using AGP's Variant Sources API (addStaticManifestFile), so the merger
pulls in exactly one component set with no module split or body duplication.
- Gate the Firebase deps, the google-services + crashlytics plugins, and the
Crashlytics buildType config on `googleBuild`, replacing the isFossOnlyBuild
task-name sniff. google-services.json is required only under -Pgoogle and
still hard-fails loudly if absent (preserves the broken-target guard).
- Collapse fossIntegrationTest into integrationTest (one task, selected by
flag); fix the release-signing task set and the Kover disabled-task list,
which referenced the now-gone per-flavor task names.
CI: per-flavor tasks no longer exist, so cover both distributions with a
two-pass matrix — assemble/test/integration/ktlint run bare (FOSS) and again
with -Pgoogle; detekt already globs both src dirs so it stays single-pass;
release uses the un-prefixed release/ output paths. Docs updated to match.
Co-authored-by: Jason Monk <monkopedia@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
8.1 KiB
Agent quickstart
Distilled commands and paths agents reach for repeatedly. Pre-loading saves grep-then-discover cycles.
JSON construction (mandatory)
Tools that return structured data MUST use @Serializable data classes plus Json.encodeToString (or Json.encodeToJsonElement if a tree is preferable). Manual template literals — """{"field":"$value"}""" — are NOT permitted in production code.
Why
#417 shipped malformed JSON to user testing because a template-literal error helper interpolated a raw exception message containing quotes, producing output the connector proxy refused to parse. The @Serializable + encodeToString path escapes embedded quotes / backslashes / control characters automatically; the manual path does not.
Two enforcement gates
- Test harness —
McpToolTestHarnessatintegrations/src/test/kotlin/com/rousecontext/integrations/testing/parse-asserts everyCallToolResultbody returned through it. Any tool whose result body failsJson.parseToJsonElementcauses the test to fail with the offending tool name and body content. - Static check —
scripts/check-no-manual-json.shruns in the Android CI workflow. Greps for"""{in*/src/main/*.ktand fails the build on unallowlisted matches.
The // allow-manual-json marker
For the rare case where a template literal is genuinely safe (e.g. a @Preview-only Compose fixture with hard-coded literals that never see runtime input), append a marker comment to the line:
val sample = """{"steps":7000}""" // allow-manual-json: @Preview fixture, no interpolation
The reason text after the colon should describe why this isn't a runtime risk — typically because no user-controlled value flows into the template. A reviewer should treat the absence of a clear reason as a blocker.
AuditDetailScreen.kt is implicitly allowlisted in the script because its preview fixtures exist purely for IDE rendering; new files should use the inline marker rather than expanding the file allowlist.
Test harness usage
When writing or refactoring an *McpProvider*Test, use McpToolTestHarness rather than hand-rolling the mockk capture pattern:
private val harness = McpToolTestHarness()
private val fakeConnection: ClientConnection = mockk(relaxed = true)
@Before fun setUp() {
val server = harness.createMockServer()
MyMcpProvider(...).register(server)
}
@Test fun `tool does the thing`() = runBlocking {
val result = harness.callTool(
name = "my_tool",
arguments = buildJsonObject { put("x", JsonPrimitive(1)) },
connection = fakeConnection
)
// Body is guaranteed JSON-parseable; assert further as needed.
}
Pass expectJsonBody = false only for tools whose contract specifies a plain-text body (rare). Document the reason inline if you do.
Module → path map
:app app/
:core:tunnel core/tunnel/
:core:mcp core/mcp/
:core:bridge core/bridge/
:core:testfixtures core/testfixtures/
:api api/
:integrations integrations/
:notifications notifications/
:work work/
:device-tests device-tests/
:e2e e2e/
relay (Rust) relay/
Source-of-truth for the module list is settings.gradle.kts:25-35.
Build & test recipes
All Gradle commands need JAVA_HOME=/usr/lib/jvm/java-21-openjdk.
# Build a debug APK. Distribution is a build flag, not a flavor (#467):
# bare = FOSS (Firebase-free; no google-services.json required)
# -Pgoogle = Firebase/FCM/Crashlytics (needs app/google-services.json)
./gradlew :app:assembleDebug
# → app/build/outputs/apk/debug/app-debug.apk (FOSS)
./gradlew :app:assembleDebug -Pgoogle
# → app/build/outputs/apk/debug/app-debug.apk (Google; overwrites the above)
# Build a release APK (shipping build is the Google distribution)
./gradlew :app:assembleRelease -Pgoogle
# → app/build/outputs/apk/release/app-release.apk
# Unit tests for one module
./gradlew :core:mcp:jvmTest # KMP/JVM
./gradlew :app:testDebugUnitTest # Android-only (FOSS)
./gradlew :app:testDebugUnitTest -Pgoogle # Android-only (Google)
./gradlew :integrations:testDebugUnitTest # Android-only
# Tunnel integration tests (against real relay binary)
./gradlew :core:tunnel:integrationTest --tests '*OAuthEndToEndTest*'
# E2E (against a real device — requires adb host + serial)
./gradlew :e2e:e2eTest -Dadb.host=adolin -Dadb.serial=1B151FDEE008EY
# Lint & analysis (whole tree)
./gradlew ktlintCheck detekt
# Format
./gradlew ktlintFormat
Relay (Rust)
cd relay
cargo test
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt
NEVER build the relay on the GCP VPS. The PR-to-main triggers .github/workflows/relay-deploy.yml (or similar) which cross-compiles + deploys.
GitHub CLI patterns
# Concise CI status
gh pr checks NNN --json name,state --jq '.[] | "\(.name)\t\(.state)"'
# Open PR
gh pr create --title "..." --body "..."
# Merge + delete branch (squash)
gh pr merge NNN --squash --delete-branch
# Issue management
gh issue create --title "..." --body "..." --label in-progress
gh issue edit NNN --add-label in-progress
gh issue edit NNN --remove-label in-progress
gh issue close NNN --comment "..."
# Just the body of a comment thread
gh issue view NNN --comments --json comments --jq '.comments[].body'
Local-branch-deletion gotcha
If gh pr merge --delete-branch fails because another worktree has the branch checked out:
cd /home/jmonk/git/rouse-context && git checkout --detach origin/main && git branch -d <branch>
adb via SSH (test device)
# adolin Pixel 6 Pro (agent-controlled)
ADB="ANDROID_SERIAL=1B151FDEE008EY /opt/android-sdk/platform-tools/adb"
ssh adolin "$ADB <command>"
# Common operations
ssh adolin "$ADB shell pidof com.rousecontext.debug"
ssh adolin "$ADB shell pm clear com.rousecontext.debug"
ssh adolin "$ADB shell am start -n com.rousecontext.debug/com.rousecontext.app.MainActivity"
ssh adolin "$ADB shell run-as com.rousecontext.debug ls files/"
ssh adolin "$ADB logcat -d -s 'Onboarding:*' 'TunnelClient:*' '*:S'" # filter at logcat level
# Kill app cleanly (NOT am force-stop — that disables FCM until manual relaunch).
# Use cmd activity stop-app: kills the process including foreground services
# but leaves FCM reachable. See #394.
ssh adolin "$ADB shell cmd activity stop-app com.rousecontext.debug"
# Install
scp app-debug.apk adolin:/tmp/
ssh adolin "$ADB install -r /tmp/app-debug.apk"
UI automation on device
Drive Android UI via uiautomator dump XML, NOT screencap PNGs (resolution blows up context). See /tmp/drive_onboard.sh for the pattern (extract bounds via regex on text="..."[^>]*bounds="[x1,y1][x2,y2]").
Relay over gcloud
gcloud compute ssh relay --zone=us-central1-a --command='sudo journalctl -u rouse-relay -f'
gcloud compute ssh relay --zone=us-central1-a --command='systemctl status rouse-relay | head -5'
Common files to know
core/tunnel/src/jvmMain/.../OnboardingFlow.kt— canonical onboarding sequenceapp/src/main/.../OnboardingViewModel.kt— canonical VM (post-#392 single-VM invariant)app/src/main/.../navigation/AppNavigation.kt+Routes.kt— nav graph + routescore/tunnel/src/jvmMain/.../TunnelClientImpl.kt— tunnel clientcore/tunnel/src/jvmMain/.../MuxFrame.kt/MuxDemux.kt— mux protocolrelay/src/api/— relay HTTP/WS endpointsrelay/src/passthrough.rs— TLS-passthrough decision + FCM wakerelay/src/rate_limit.rs— rate limiters (FcmWakeThrottle, etc.)docs/design/relay-api.md— endpoint catalog (kept current as of #410)docs/design/android-app.md— module map + dep graph (kept current as of #408)docs/design/overall.md— system overview (kept current as of #409)docs/ux-decisions.md— UX flow decisions log
When in doubt
- Code is the source of truth over docs.
- A
.claude/rules/*rule is the source of truth over a memory entry. - The user's most recent in-session direction overrides everything else.