Design systems on Android · Series · Part 6 of 6
Shipping an Android design system: publishing, CI, docs and the bugs I hit
A design system that only lives in its author's project is a style guide with extra steps. It becomes a system when other apps can depend on it, when changes are checked automatically, and when the documentation stays true. This final part covers publishing the library, CI, the docs site and the sample as living documentation, then collects the real bugs hit while building the example, because those are the most transferable lessons.
Working code. Everything in this post exists and runs in the reference repository: github.com/liconrepo/MobileDesignSystem. Clone it and follow along; the docs are also published at liconrepo.github.io/MobileDesignSystem.
Files for this part:
- Turn on Kotlin
explicitApi()so every public declaration is intentional. - Publish the Android library with
maven-publishand areleasevariant; test it first withmavenLocal(). - CI runs unit tests, lint and builds the sample on every push and pull request.
- The sample app and the docs site are part of the product. Keep them in the same repo.
- Bugs found while building: padding overwritten, state-restore firing a listener, a recreated window losing edge-to-edge, and one wrong test premise.
A small, deliberate public API
A library's public surface is a promise. Kotlin can enforce discipline about it: in explicit API mode the compiler refuses a declaration that does not state its visibility, and a public one must state its return type.
kotlin {
// A design system is public API first: every declaration must state its visibility.
explicitApi()
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
}
dependencies {
api(libs.androidx.appcompat) // consumers see AppCompat types through our views
implementation(libs.androidx.core.ktx)
}The choice between api and implementation is itself a design decision. AppCompat is api because the components extend its classes, so consumers need it on their compile classpath. Core KTX is only used inside, so it is implementation and does not leak.
Helpers that components share but consumers should not use (stateColors, roundedRect, rippleOf) are internal. Those that consumers genuinely need (mdsColor, mdsPx) are public and documented.
Publishing the library
An Android library publishes an AAR plus a Gradle module file. The relevant configuration is small:
android {
publishing {
singleVariant("release") { withSourcesJar() }
}
}
afterEvaluate {
publishing {
publications {
register<MavenPublication>("release") {
from(components["release"])
groupId = "io.github.liconrepo"
artifactId = "mds"
version = project.version.toString()
pom { name.set("Mobile Design System"); ... }
}
}
}
}afterEvaluate is needed because the Android Gradle Plugin creates the release component late. Always test the publication on your own machine first, before anything public:
./gradlew :designsystem:publishReleasePublicationToMavenLocal
Then in a throwaway project:
repositories { mavenLocal() }
dependencies { implementation("io.github.liconrepo:mds:0.1.0") }
A real release also needs a version tag. Options, from lightest to heaviest:
| Route | Effort | Note |
|---|---|---|
| JitPack | Tag the repo, add jitpack.yml | Builds on demand. Check the JitPack page for the exact coordinates of a multi-module build after its first build. |
| GitHub Packages | Add a publishing repository and a token | Consumers need credentials, even for public packages. |
| Maven Central | Namespace verification, signing, a publishing plugin | Most work, widest reach. |
The example repo is configured for local publishing and JitPack. It has not been published to a public registry, which is the honest state of the version in this series.
Semantic versioning for a design system
| Change | Version bump |
|---|---|
| Fix a bug, tune a colour inside contrast limits | Patch |
| Add a component, variant or token | Minor |
| Rename or remove a token, attribute or public method; change a default that moves layouts | Major |
Tokens and XML attribute names are API too. Renaming mdsColorPrimary breaks every consumer theme even though no Kotlin changed. A changelog (CHANGELOG.md in the repo) is part of the contract.
Continuous integration
The workflow is deliberately short, so it stays green and trusted:
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: 17 }
- uses: gradle/actions/setup-gradle@v4
- name: Unit tests
run: ./gradlew :designsystem:testDebugUnitTest
- name: Lint
run: ./gradlew :designsystem:lintDebug
- name: Build sample
run: ./gradlew :sample:assembleDebugBuilding the sample on every change is the point that is often skipped. It is the cheapest test that a public API change did not break a real consumer.
Two further additions are worth planning but are not in the example yet: screenshot tests, so a pixel-level regression in a component is caught, and a binary-compatibility check, so an accidental public API change fails the build.
Test the logic that is not a view
Views are hard to unit test; pure functions are easy. The avatar's initials and colour-choice logic is pulled into a plain object with its own tests:
@Test fun `two words use first and last initial`() =
assertEquals("AL", AvatarText.initials("ada king lovelace"))
@Test fun `tone is stable and case-insensitive`() {
assertSame(AvatarText.tone("Grace Hopper"), AvatarText.tone(" grace hopper "))
}
Docs and sample as part of the product
- Docs as Markdown in the repo.
docs/principles.md,tokens.md,theming.md,layers.md,components.mdandadding-a-component.mdchange in the same pull request as the code they describe. The repo also publishes them as a website at liconrepo.github.io/MobileDesignSystem. - A contributor checklist (
adding-a-component.md) turns taste into steps: justify it, name it, use tokens, set background before padding, handle state, keep helpers internal, add it to the sample and the changelog. - The sample reads tokens live. Its colour, type and spacing tables are generated from the current theme, so they cannot go stale.
Bugs I hit, and what they teach
None of these are exotic. All of them cost time, which is why they are worth listing.
| Symptom | Cause | Fix and lesson |
|---|---|---|
| Wrap-content buttons and chips had no horizontal padding | setBackground with an InsetDrawable overwrote padding that had been set earlier | Set background first, padding second. Check small, wrap-content cases, not only full-width ones. |
| Two text fields lost or swapped text after rotation | Child views with the same id collide in saved state | Freeze the compound view as one unit (Part 4). |
| A disabled field looked enabled | The XML flag was not reliably visible during construction | Read it from the component's own styleable and apply it at the end of init. Cause not isolated. |
| Dark mode flipped itself back | State restore changed a switch, firing a listener that wrote the preference | isSaveEnabled = false and only react to isPressed changes. |
| After switching theme the screen had blank bars at top and bottom | On an API 35 emulator, an Activity recreated by recreate() lost its edge-to-edge window while a fresh launch did not | Relaunch with a new Activity instead (below). |
A nested style in values-night dropped the theme | A qualified style replaces the whole style | Use resources (a boolean) for per-mode differences (Part 2). |
The theme-switch bug and the test that lied
The blank bars showed up only after a theme or brand switch. Cold starts looked perfect. I then spent a while "bisecting" by disabling parts of my insets code, and certain variants appeared to fix it. They did not. Without the top padding, the layout sat higher on screen by the height of the status bar, so my scripted tap on the dark-mode switch landed on empty space. The activity was never recreated, so of course there was no bug. The check was invalid, not the fix.
The lesson is general: before trusting a "works now" result, confirm the test actually did what you think it did. A log line in onCreate proved it in seconds.
What the sample does now:
/**
* Relaunches as a fresh Activity instead of calling recreate(): on API 35 a recreated edge-to-edge
* window kept the system bars' insets as padding on the emulator, a fresh launch does not.
*/
private fun restart() {
startActivity(Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION))
finish()
@Suppress("DEPRECATION")
overridePendingTransition(0, 0)
}The manifest declares android:configChanges="uiMode" so a system dark-mode switch arrives at onConfigurationChanged, which calls restart(). I verified this on an emulator, not on a range of physical devices, and I did not find the framework-level cause, so treat it as a workaround that worked rather than as a diagnosis. Keep your own device matrix in mind.
Adopting this in a real company
- Start from audit, not from components. List the colours, sizes and button styles in your app. The count is usually surprising and makes the case.
- Tokens first. Introduce semantic colour roles and migrate screens to them before building a single component.
- Build the three components you use most. Usually a button, a text field and a card. Ship them, then ask what is missing.
- Write the principles down and put them where decisions are made: in pull request templates.
- Make contribution easy. A system owned by one team becomes a bottleneck; the checklist and sample make contributions reviewable.
- Measure adoption. Count remaining hard-coded colours and one-off button styles, and watch the number fall.
- Mind your boundaries. If you build one while employed, keep your employer's code, assets and internal knowledge out of public work, and check your contract before publishing.
What this series left out
To keep the example small, it does not cover: Compose (by design), right-to-left layout testing, large-screen and foldable layouts, localisation beyond string resources, a Figma token pipeline, or automated screenshot tests. Each is a good next contribution, and the repo's issue tracker is the place to propose them.
Try it yourself
- Publish to
mavenLocal()and consume the library from a brand-new Android project. ApplyTheme.Mdsand use one button. - Break something on purpose, such as removing an
explicitApimodifier or a test, and watch CI catch it on a pull request. - Pick one item from "what this series left out", implement it, and open a pull request against the repo.
| Term | In plain words |
|---|---|
| explicitApi | A Kotlin mode that forces every public declaration to say so. |
| AAR | The packaged form of an Android library. |
| mavenLocal | A folder on your machine that Gradle can read libraries from. |
| Semantic versioning | Patch, minor and major numbers that signal how risky an upgrade is. |
| CI | Automatic checks that run on every change. |
| Edge-to-edge | Drawing under the system bars and padding for them yourself. |