Skip to content

Android Project Docs with MkDocs, 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 covers 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
  • Android project with the Gradle version catalog (libs.versions.toml)
  • Firebase project with a service account holding 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 sufficient for project documentation. The missing piece is glue: a Gradle task that builds both into the same output directory, a firebase.json that points at it, and a workflow that runs without manual intervention.

The most common setup mistake

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 runs both in sequence into the same html/ folder.


Implementation

1. Register Dokka in the version catalog

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

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

Adding it to the version catalog keeps the version in one place and makes it available to every module.

2. Apply the plugin at root and module level

build.gradle.kts
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")
}

apply false at the root level prevents the plugin from generating tasks where it has nothing to document. The subprojects block registers dokkaHtml in every module. Running ./gradlew dokkaHtml from the root generates HTML under each module's build/dokka/html/ directory.

Multi-module projects

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

3. Scaffold the MkDocs site

Create a documentation/ directory at the project root:

documentation/
├── docs/
│   ├── index.md
│   ├── architecture.md
│   ├── guidelines.md
│   └── api.md
├── mkdocs.yml
└── generate_docs.sh
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 resolves relative to mkdocs.yml, so output lands at documentation/html/. Firebase Hosting is configured to serve from that exact path. Both values must stay in sync.

4. Write the build script

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

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

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

5. Register the buildDocs Gradle task

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")
            }
        }
    }

generate_docs.sh runs first, building the MkDocs site. The doLast block runs dokkaHtml after the script exits, writing the API reference into the already-built site. Running ./gradlew buildDocs locally should produce documentation/html/ with the full site and documentation/html/api/ with the Dokka output.

Do not commit the html/ directory

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

6. Configure Firebase Hosting

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

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

7. Write the GitHub Actions workflow

.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 on every PR. To get 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 PROJECT_ID, not secrets

PROJECT_ID is not sensitive: it is visible in the Firebase console and in .firebaserc. GitHub repository variables (vars) are the right home for it. Reserve secrets for the service account JSON.

8. Store the required credentials

In your repository under Settings, Secrets and variables, Actions:

Name Type Value
FIREBASE_SERVICE_ACCOUNT Secret Service account JSON
SLACK_WEBHOOK_URL Secret Incoming webhook URL
PROJECT_ID Variable Firebase project ID

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 and Verification

Run the full build locally before pushing:

terminal
./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

Confirm the output directory structure:

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

If api/ is missing, dokkaHtml did not write to the expected location. Check that no DokkaTaskPartial.outputDirectory override is redirecting the output.


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 it, the CI Exec task fails with Permission denied even though the script runs fine locally.

site_dir must be relative to mkdocs.yml

site_dir: html in mkdocs.yml resolves relative to the file's location at 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/, not the project root. The buildDocs task runs dokkaHtml after MkDocs builds 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 switch to dokkaHtmlMultiModule at the root with a shared output path.


Production Considerations

channelId: live 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.

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

If the project 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 natural next step 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