Skip to content

Android Project Docs with MkDocs Material, Dokka, and Firebase Hosting

Most Android projects ship without documentation. The few that have it rely on a Confluence page that is out of date by the time it is merged. This post shows the setup used in Unizonn Mobile v2: MkDocs Material for the documentation site, Dokka for generated API reference, Firebase Hosting for deployment, and a single GitHub Actions workflow that builds and ships everything on every PR targeting main or staging. The result is a live URL that reviewers can open from a pull request without cloning anything.

Prerequisites

Python 3.12, JDK 17, and a working Android project with the Gradle version catalog (libs.versions.toml) are assumed. The setup uses org.jetbrains.dokka 1.9.20 and mkdocs-material (latest pip release). You will need a Firebase project and a service account with the Firebase Hosting Admin role.

Versions used in this post

Kotlin 1.9.0 · AGP 8.3.2 · Dokka 1.9.20 · Python 3.12.3 · MkDocs Material (pip latest)

Background

Dokka generates HTML from KDoc comments. MkDocs Material turns Markdown into a polished site. Firebase Hosting serves static assets from a CDN with a free tier that is more than enough for project documentation. The missing piece is glue: a Gradle task that builds both, a firebase.json that points at the right output directory, and a workflow that runs it without manual intervention.

What most setups get wrong

Running mkdocs build and dokkaHtml as separate CI steps without a shared output directory means the API reference is never served alongside the site. The buildDocs task below solves this by running both in sequence into the same html/ folder.

Implementation

1. Register Dokka in the version catalog

Add Dokka to gradle/libs.versions.toml before touching any build files. This keeps the version in one place and makes it available to every module.

gradle/libs.versions.toml
[versions]
dokka = "1.9.20"

[plugins]
dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }

2. Apply the plugin at the root and module level

The root build.gradle.kts declares the plugin without applying it, then applies it to every subproject. The apply false pattern prevents the plugin from generating tasks at the root level where it has nothing to document.

build.gradle.kts
1
2
3
4
5
6
7
8
9
plugins {
    alias(libs.plugins.androidApplication) apply false
    alias(libs.plugins.jetbrainsKotlinAndroid) apply false
    alias(libs.plugins.dokka) apply false
}

subprojects {
    apply(plugin = "org.jetbrains.dokka")
}

This registers dokkaHtml as a task in every subproject. Running ./gradlew dokkaHtml from the root will generate HTML under each module's build/dokka/html/ directory.

Multi-module output directory

For a single-module project, dokkaHtml output lands at app/build/dokka/html/. If you later add modules, switch to dokkaHtmlMultiModule at the root level and point outputDirectory at documentation/html/api/. The buildDocs task below handles both just update the dokkaHtml call to dokkaHtmlMultiModule.

3. Write the MkDocs site scaffold

Create a documentation/ directory at the project root. This keeps the site separate from the Android source tree and gives CI a clean working directory.

documentation/
├── docs/
│   ├── index.md
│   ├── architecture.md
│   ├── guidelines.md
│   └── api.md          ← placeholder that redirects to api/index.html
├── mkdocs.yml
└── generate_docs.sh

The mkdocs.yml configures the Material theme with a light/dark palette toggle. The nav entry for API points to the api/index.html that Dokka will generate into this directory.

documentation/mkdocs.yml
site_name: Unizonn Mobile Documentation
site_dir:  html
theme:
  name: material
  language: en
  palette:
    - scheme: default
      toggle:
        icon: material/toggle-switch-off-outline
        name: Switch to dark mode
      primary: teal
      accent: purple
    - scheme: slate
      toggle:
        icon: material/toggle-switch
        name: Switch to light mode
      primary: teal
      accent: lime

nav:
  - Getting Started: index.md
  - Guidelines:      guidelines.md
  - Architecture:    architecture.md
  - API:             api/index.html

plugins:
  - search

site_dir: html is deliberate. Firebase Hosting will be configured to serve from documentation/html/, so both MkDocs output and Dokka output must land there.

4. Write the build script

The generate_docs.sh script creates a Python virtual environment, installs mkdocs-material, and runs mkdocs build. Keep it as a shell script rather than a Gradle Exec task so it can be run locally without Gradle.

documentation/generate_docs.sh
#!/bin/bash
set -ex

python3 -m venv venv
source venv/bin/activate
pip3 install mkdocs-material
mkdocs build

set -ex means the script fails immediately on any error and prints each command before executing it. Without this, a failed pip install silently produces an empty html/ directory and the CI step succeeds with nothing deployed.

5. Register the buildDocs Gradle task

Back in the root build.gradle.kts, register a buildDocs task that runs generate_docs.sh first, then runs dokkaHtml. The doLast block runs after generate_docs.sh exits, so Dokka output is written into the already-built MkDocs site.

build.gradle.kts
val buildDocs =
    tasks.register<Exec>("buildDocs") {
        workingDir = file("$rootDir/documentation")
        commandLine = listOf("./generate_docs.sh")
        doLast {
            project.exec {
                workingDir = project.rootDir
                commandLine = listOf("./gradlew", "dokkaHtml")
            }
        }
    }

Running ./gradlew buildDocs locally should produce documentation/html/ with the full MkDocs site and documentation/html/api/ with the Dokka output.

Do not commit the html/ directory

Add documentation/html/ and documentation/venv/ to .gitignore. The build output is regenerated on every CI run. Committing it produces multi-megabyte diffs and creates merge conflicts that have nothing to do with content changes.

6. Configure Firebase Hosting

Firebase Hosting needs to know where the built site lives. The firebase.json at the project root points at documentation/html/, which is exactly where both MkDocs and Dokka write their output.

firebase.json
{
  "hosting": {
    "public": "documentation/html",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ]
  }
}

Run firebase init hosting once to generate the .firebaserc file with your project ID. Commit .firebaserc but not firebase-debug.log.

7. Write the GitHub Actions workflow

The workflow triggers on pull requests targeting main or staging. It sets up Python, JDK 17, and Gradle, runs ./gradlew buildDocs, then deploys to Firebase using the official action. The final step notifies a Slack channel regardless of whether the deploy succeeded or failed.

.github/workflows/docs.yml
name: Build documentation

on:
  pull_request:
    branches:
      - main
      - staging

jobs:
  deploy_documentation:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12.3'

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: 17
          distribution: 'temurin'
          cache: gradle

      - name: Set up Gradle
        uses: gradle/actions/setup-gradle@v3

      - name: Build documentation
        run: ./gradlew buildDocs

      - name: Deploy to Firebase Hosting
        uses: FirebaseExtended/action-hosting-deploy@v0
        with:
          firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT }}"
          projectId: "${{ vars.PROJECT_ID }}"
          channelId: live

      - name: Notify Slack
        uses: act10ns/slack@v2.0.0
        with:
          webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
          status: ${{ job.status }}
          message: "Unizonn Mobile Documentation is live at https://unizonn-******.app/"

channelId: live deploys to the live channel rather than a preview channel. If you want per-PR preview URLs instead, remove this line the action will create a temporary channel and post the URL back to the PR as a comment.

Use vars for the project ID, not secrets

PROJECT_ID does not need to be secret it is visible in the Firebase console and in .firebaserc. GitHub vars (plain variables) are the right home for it. Reserve secrets for the service account JSON.

8. Store the required secrets

In your GitHub repository settings under Settings → Secrets and variables → Actions:

Name Type Value
FIREBASE_SERVICE_ACCOUNT Secret Service account JSON (base64 or raw)
SLACK_WEBHOOK_URL Secret Incoming webhook URL from Slack
PROJECT_ID Variable Firebase project ID (e.g. unizonn-mobile-v2)

The service account needs the Firebase Hosting Admin IAM role. Create it in the GCP console under the Firebase project, download the JSON key, and paste the contents into the secret.

Testing

Verify the full build locally before pushing. From the project root:

./gradlew buildDocs

Expected output:

> Task :buildDocs
+ python3 -m venv venv
+ source venv/bin/activate
+ pip3 install mkdocs-material
...
INFO    -  Building documentation to directory: /path/to/documentation/html
INFO    -  Documentation built in 2.34 seconds

> Task :app:dokkaHtml
...
BUILD SUCCESSFUL in 38s

Then confirm the directory structure before any Firebase deploy:

ls documentation/html/
# index.html  404.html  assets/  api/  ...

If api/ is missing, dokkaHtml did not write to the right location. Check that DokkaTaskPartial.outputDirectory is not overriding the default path.

Pitfalls

generate_docs.sh must be executable

git does not preserve the executable bit across all platforms by default. Run git update-index --chmod=+x documentation/generate_docs.sh and commit the result. Without this, the CI Exec task will fail with Permission denied even though it works locally.

MkDocs site_dir must be relative to mkdocs.yml

site_dir: html in mkdocs.yml resolves relative to the file's location, which is documentation/. The output lands at documentation/html/. Setting site_dir: ../html or an absolute path breaks the Firebase Hosting config, which explicitly points at documentation/html.

Dokka output path when using subprojects {}

Applying Dokka via subprojects { apply(plugin = "org.jetbrains.dokka") } generates output under each module's own build/dokka/html/ directory, not the root. The buildDocs task runs dokkaHtml after MkDocs has already built the site, so Dokka output does not end up inside documentation/html/api/ automatically. Either configure outputDirectory explicitly in each module's Dokka task, or use dokkaHtmlMultiModule at the root level with a shared output path.

Production considerations

The channelId: live setting means every PR deploy goes straight to the live URL. For teams where documentation changes go through review, switch to preview channels and add a separate workflow that promotes to live on merge to main.

The generate_docs.sh script reinstalls mkdocs-material on every run because it creates a fresh virtual environment each time. Pin the version (pip3 install mkdocs-material==9.5.x) and cache the virtual environment using actions/cache keyed on requirements.txt to cut the step from roughly 30 seconds to under 5.

If the documentation site grows beyond a single module, replace dokkaHtml with dokkaHtmlMultiModule and configure each module's DokkaTaskPartial with an outputDirectory that writes into documentation/html/api/. The Firebase config and workflow do not need to change.

Wrapping up

The result is a ./gradlew buildDocs command that builds the full documentation site and a GitHub Actions workflow that ships it to a live URL on every PR no manual deploys, no stale Confluence pages.

The next thing to add is versioned docs: a separate Firebase Hosting channel per release tag, so older API reference stays accessible after upgrades. That is a firebase hosting:channel:deploy v1.0 call away from the same workflow.


Comments