Building a Frictionless Android Sample App: README Funnel, Doctor Script, and a CI Compatibility Matrix
My AI-assisted learning log on turning an Android SDK demo into a low-friction client experience: a decision-tree README, environment doctor scripts, and a GitHub Actions build matrix that generates a compatibility matrix.
Table of contents
- Context: What I’m Building
- What I Learned / Architecture Decisions
- 1) A README should be a friction-removal funnel
- 2) Don’t debate compatibility—measure it
- 3) Parameterize build inputs via Gradle properties + environment variables
- Key Concepts Explained
- Compatibility Matrix vs Build Matrix
- Why pluginManagement {} placement matters
- Robolectric + ViewModel + SavedStateHandle gotchas
- Code Snippets & Scenarios
- 1) Making plugin versions configurable in settings.gradle
- 2) Making compileSdk / targetSdk configurable
- 3) Doctor scripts: turning mystery failures into actionable output
- 4) CI Build Matrix: one previous + current + next
- What Worked, What Didn’t
- What worked
- What didn’t
- What I’d Do Differently
- References / Further Reading
Context: What I’m Building
I maintain an Android Mobile SDK published to Maven, plus a public sample app that demonstrates the SDK flow for prospective clients. In theory, the sample app is the fastest way to understand the SDK. In practice, clients cloning the repo hit environment issues:
- Wrong JDK version or mismatched Gradle JVM settings
- Gradle / Android Gradle Plugin (AGP) incompatibilities
- Missing Android SDK / build-tools / NDK on their machine
- Compose / Kotlin plugin mismatches
- CI differences from local machines
The recurring pattern: even when my SDK worked, the demo experience didn’t. So I treated “can someone run this quickly?” as a product requirement.
The project goal for this sprint became: remove resistance to running the sample app.
What I Learned / Architecture Decisions
1) A README should be a friction-removal funnel
Instead of a README that says “do these 5 steps,” I designed it like a funnel:
- Start with prerequisites (what must be installed)
- Provide one command that diagnoses common failure modes
- Offer decision-tree troubleshooting (“If you see X, do Y”)
- Only then explain how to run the demo and inject tokens
Real-world analogy: a good README is like airport signage. Most people should reach the gate without asking staff. The signage doesn’t teach aviation—it reduces confusion.
2) Don’t debate compatibility—measure it
I initially thought a “minimum + recommended” table was enough. But it quickly turned into guesswork:
- My repo uses specific versions today.
- Clients show up with older Android Studio/AGP/JDK combos.
- Kotlin/Compose version constraints create hidden breakpoints.
So I decided to build a CI pipeline that runs a build matrix across controlled version combinations. The compatibility matrix becomes the output of CI, not a hand-written promise.
Real-world analogy: instead of a restaurant claiming “we serve everyone,” you publish the allergens and ingredients you tested.
3) Parameterize build inputs via Gradle properties + environment variables
To support a CI build matrix, the sample app needs its versions and SDK levels to be configurable without rewriting files.
So I moved key knobs to:
settings.gradlepluginManagementfor AGP + Kotlin plugin versionsgradle.propertiesandSystem.getenv(...)for compileSdk/targetSdk, and (optionally) other toggles
Real-world analogy: you don’t rewire a house to change the light brightness—you add a dimmer switch.
Key Concepts Explained
Compatibility Matrix vs Build Matrix
- A build matrix is a list of version combinations CI actually builds.
- A compatibility matrix is the document generated from those results.
If the build matrix is “testing recipes,” the compatibility matrix is “the cookbook page that shows which recipes succeeded.”
Why pluginManagement {} placement matters
Gradle has strict rules for settings.gradle: pluginManagement {} must appear before any other statements. I learned this the hard way when I tried defining variables above it.
The fix was simple: define versions inside pluginManagement using providers.gradleProperty(...) or environment variables.
Robolectric + ViewModel + SavedStateHandle gotchas
When Compose calls viewModel() inside a Composable, ViewModel construction can happen implicitly during tests. If the ViewModel constructor reads from SavedStateHandle and expects navigation arguments, missing or mismocked data can crash tests immediately.
The learning: mock navigation argument access before the Composable renders.
Code Snippets & Scenarios
1) Making plugin versions configurable in settings.gradle
The key was to allow CI to set versions without editing files:
// settings.gradle
pluginManagement {
def agpVersion = System.getenv("AGP_VERSION")
?: (providers.gradleProperty("AGP_VERSION").orNull ?: "8.7.2")
def kotlinVersion = System.getenv("KOTLIN_VERSION")
?: (providers.gradleProperty("KOTLIN_VERSION").orNull ?: "1.9.25")
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
plugins {
id "com.android.application" version agpVersion
id "com.android.library" version agpVersion
id "org.jetbrains.kotlin.android" version kotlinVersion
}
}
Scenario: CI can build the same repo with multiple AGP + Kotlin plugin versions by setting environment variables.
2) Making compileSdk / targetSdk configurable
I avoided hardcoding SDK levels by pushing them into properties:
// app/build.gradle
android {
def compileSdkOverride = System.getenv("COMPILE_SDK")
?: (project.findProperty("COMPILE_SDK") ?: "34")
def targetSdkOverride = System.getenv("TARGET_SDK")
?: (project.findProperty("TARGET_SDK") ?: "34")
compileSdk Integer.parseInt(compileSdkOverride)
defaultConfig {
minSdk 24
targetSdkVersion Integer.parseInt(targetSdkOverride)
}
}
Scenario: Some clients run older build setups. CI can validate a previous SDK level (e.g., 33) and “next” (e.g., 35 preview only if supported) without branching the repo.
3) Doctor scripts: turning mystery failures into actionable output
I created “doctor” scripts for macOS/Linux (bash) and Windows (PowerShell). The scripts check:
java -versionand a recommended JDK range- Gradle wrapper presence and wrapper version parsing
- Android SDK environment variables and common paths
- Availability of
sdkmanagerand installed platform packages
A simplified check pattern:
# scripts/doctor.sh
if command -v java >/dev/null 2>&1; then
java -version
else
echo "❌ Java not found"
echo "↳ Fix: Install JDK 17+ and set JAVA_HOME"
fi
Real-world use case: a client runs one script, pastes output into a support ticket, and the fix is obvious.
4) CI Build Matrix: one previous + current + next
I structured GitHub Actions to build APKs across combinations:
- Gradle (wrapper) stays fixed per repo
- AGP: previous/current/next (where possible)
- Kotlin: previous/current/next (compatible with AGP)
- compileSdk/targetSdk: previous/current/next (supported)
Then a Python step aggregates results into JSON and updates compatibility-matrix.md.
Pseudo-shape of the job:
strategy:
fail-fast: false
matrix:
agp: ["8.6.1", "8.7.2", "8.8.0"]
kotlin: ["1.9.24", "1.9.25", "2.0.0"]
compileSdk: ["33", "34", "35"]
Important nuance: not every combination is valid. I learned to treat the build matrix as a curated set, not a cartesian explosion.
What Worked, What Didn’t
What worked
- Decision-tree README: clients know where to start and what to try next.
- Doctor script: removed the “it doesn’t work” ambiguity.
- Gradle property overrides: enabled CI to test variants without committing changes.
- Splitting “compat doc” from README: README stays short; the matrix is detailed.
What didn’t
- Auto-triggering checks in Compose that caused navigation to fire before user intent.
- Over-mocking navigation helpers inconsistently (
everyvscoEvery), leading to subtle type cast errors in tests. - Treating compatibility as a static table. It drifted the moment dependencies changed.
What I’d Do Differently
- Start compatibility automation earlier. It forces clean version boundaries and prevents silent breakage.
- Keep ViewModel constructors lightweight. Anything that reads nav args or performs work should be delayed until after initialization (or behind injected dependencies).
- Treat the sample app like a product. It deserves the same DX care: onboarding, diagnostics, and clear failure messages.
References / Further Reading
(Official docs I used as anchors while reasoning about the problems.)
Android: Request runtime permissions
https://developer.android.com/training/permissions/requesting
Android Gradle Plugin release notes
https://developer.android.com/build/releases/gradle-plugin
Gradle compatibility matrix
https://docs.gradle.org/current/userguide/compatibility.html
GitHub Actions: matrix strategy
https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs
Robolectric
https://robolectric.org/
MockK documentation
https://mockk.io/ Share
More to explore
Keep exploring
1/11/2026
Kotlin to C/C++ Transition Guide: A Systems Programmer's Cheat Sheet
Preparing for a systems programming interview but haven't touched C/C++ since university? This guide bridges your Kotlin knowledge to C/C++ with side-by-side syntax comparisons, memory management deep dives, and critical undefined behaviors you need to know.
1/10/2026
The SDK Mindset: Why Your Code Isn't Your Own Anymore
A deep dive into the paradigm shift from application development to SDK design, and why building libraries requires a fundamentally different mental model
12/15/2025
Android Learning Log: Compose Permissions, SavedStateHandle, and Testing Pitfalls
A developer-friendly learning log on fixing a Jetpack Compose permission flow, understanding SavedStateHandle/nav args, and debugging MockK + Robolectric Compose tests—complete with real-world analogies and code snippets.
Previous
The SDK Mindset: Why Your Code Isn't Your Own Anymore
Next