First of all, why?

Because we’re lazy! Well, that and we care about a standardized and reproducible build environment. Using Docker images to build our app gives ous just what we want, a reproducible build environment. Images are built once and re-used whenever we build our app. Every single build is the same as long as we don’t alter the image version.

Not having to manually create a release and ship/upload your app is not only nice and convenient, it makes it easier for someone else to take over after you assuming you’ve actually documented the pipeline.

The inspiration to write this post came from seeing Gitlabs own post Setting up GitLab CI for Android projects. This post is great but four years old, which means that, of course, Google has had plenty of time to deprecate some of the tools used.

The Docker image

To build an Android app we usually use Android studio. This is rather convenient but a headless server doesn’t handle GUI applications very well. Instead, we install the Android SDK and the tools that come with it.

We’re going to start with our base image, in this case openjdk:jdk-11.

FROM openjdk:11-jdk

Next, we’re defining some arguments/variables to easily update the tools later. The default values here can be found by running sdkmanager --list.

ARG ANDROID_COMPILE_SDK=32
ARG ANDROID_BUILD_TOOLS=32.0.0
ARG ANDROID_CMDLINE_TOOLS=8092744 # https://developer.android.com/studio#command-tools
ARG ANDROID_NDK=24.0.8215888
ARG ANDROID_USER=android # User to run the build, can be whatever really

Our next step is to update the image, install our dependencies and add the user.

RUN apt --quiet update --yes
RUN apt --quiet upgrade --yes
RUN apt --quiet install --yes wget tar unzip lib32stdc++6 lib32z1 sudo

RUN mkdir -p /etc/sudoers.d

RUN groupadd --gid 1000 ${ANDROID_USER} \
    && useradd --uid 1000 --gid ${ANDROID_USER} --shell /bin/bash --create-home ${ANDROID_USER} \
    && echo '${ANDROID_USER} ALL=NOPASSWD: ALL' >> /etc/sudoers.d/50-${ANDROID_USER}
USER ${ANDROID_USER}

Add our to-be-installed tool paths to our PATH.

ENV ANDROID_HOME=/home/${ANDROID_USER}/Android/Sdk
ENV PATH=/home/${ANDROID_USER}/Android/Sdk/cmdline-tools/latest/bin:${PATH}
ENV PATH=/home/${ANDROID_USER}/Android/Sdk/platform-tools:${PATH}
ENV PATH=/home/${ANDROID_USER}/Android/Sdk/ndk/${ANDROID_NDK}/toolchains/llvm/prebuilt/linux-x86_64/bin:${PATH}

With all dependencies installed and our user created and switched to, install the Android toolchain.

RUN cd /home/${ANDROID_USER} && mkdir -p Android/Sdk/cmdline-tools
WORKDIR /home/${ANDROID_USER}/Android
RUN wget --quiet --output-document=android-sdk.zip https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS}_latest.zip
RUN unzip -d Sdk/cmdline-tools android-sdk.zip
RUN mv Sdk/cmdline-tools/cmdline-tools Sdk/cmdline-tools/latest
RUN rm android-sdk.zip

# Install SDK parts
RUN echo y | sdkmanager "platforms;android-${ANDROID_COMPILE_SDK}" >/dev/null
RUN echo y | sdkmanager "platform-tools" >/dev/null
RUN echo y | sdkmanager "build-tools;${ANDROID_BUILD_TOOLS}" >/dev/null
RUN echo y | sdkmanager "ndk;${ANDROID_NDK}" >/dev/null

Finally, create a project directory and set the working directory.

RUN cd /home/${ANDROID_USER} && mkdir -p Project
WORKDIR /home/${ANDROID_USER}/Project

The Dockerfile is done! You can build an image with docker image build -t my-android-image .. Once the build finishes, you can run the image with docker run -it -v $(pwd):/home/android/Project my-android-image /bin/bash.

Don’t forget to upload the image to a registry to be able to use it on other machines, sush as a CI service.

Gitlab ci config

We want to build the image every time someone opens a merge request but we only want new releases when we merge into the main branch.

We have two stages, build and release.

stages:
  - build
  - release

Our first stage, build, consits of only one job, assemble. Notice the before_script and reports sections. They’re important to keep if we want to create a release with a link to the built apk.

The before_script section runs before our actual build, the only purpose is to save the build number as that is used in the download URL in the release.

The reports section tells gitlab to keep the assemble.env-file arround for other jobs that request it.

assemble:
  stage: build
  image: my-android-image
  before_script:
    - echo $CI_JOB_ID
    - echo GE_JOB_ID=$CI_JOB_ID >> assemble.env
  script:
    - ./gradlew assembleRelease
  artifacts:
    paths:
      - app/build/outputs/apk/release
    reports:
      dotenv: assemble.env

Creating a new Gitlab release is pretty easy, gitlab conveniently provides a tool for just this. Notice the use of ${GE_JOB_ID} in the asset link, also make sure to change my-group-placeholder/my-project-placeholder to your actual group and project slugs.

release:
  stage: release
  image: registry.gitlab.com/gitlab-org/release-cli:latest
  script:
    # This is the variable we saved in the 'before_script' section in the assemble job
    - echo "Running release for job"
    - echo $GE_JOB_ID
  # We 'need' the assemble job, otherwise we wouldn't have anything to release.
  needs:
    - job: assemble
      artifacts: true
  release:
    name: Release $CI_COMMIT_SHORT_SHA
    description: Create release $CI_COMMIT_SHORT_SHA using the release-cli
    tag_name: $CI_COMMIT_SHORT_SHA
    assets:
      links:
        - name: "app-release.apk"
          url: "https://gitlab.com/my-group-placeholder/my-project-placeholder/-/jobs/${GE_JOB_ID}/artifacts/raw/app/build/outputs/apk/release/app-release.apk"
  # Only run this job on the main branch
  only:
    - main

We’re done!

Here’s our Dockerfile

FROM openjdk:11-jdk

ARG ANDROID_COMPILE_SDK=32
ARG ANDROID_BUILD_TOOLS=32.0.0
ARG ANDROID_CMDLINE_TOOLS=8092744
ARG ANDROID_NDK=24.0.8215888
ARG ANDROID_USER=android

# System
RUN apt --quiet update --yes
RUN apt --quiet upgrade --yes
RUN apt --quiet install --yes wget tar unzip lib32stdc++6 lib32z1 sudo

RUN mkdir -p /etc/sudoers.d

# Add user
RUN groupadd --gid 1000 ${ANDROID_USER} \
    && useradd --uid 1000 --gid ${ANDROID_USER} --shell /bin/bash --create-home ${ANDROID_USER} \
    && echo '${ANDROID_USER} ALL=NOPASSWD: ALL' >> /etc/sudoers.d/50-${ANDROID_USER}
USER ${ANDROID_USER}

# Env
ENV ANDROID_HOME=/home/${ANDROID_USER}/Android/Sdk
ENV PATH=/home/${ANDROID_USER}/Android/Sdk/cmdline-tools/latest/bin:${PATH}
ENV PATH=/home/${ANDROID_USER}/Android/Sdk/platform-tools:${PATH}
ENV PATH=/home/${ANDROID_USER}/Android/Sdk/ndk/${ANDROID_NDK}/toolchains/llvm/prebuilt/linux-x86_64/bin:${PATH}

# Android tools
RUN cd /home/${ANDROID_USER} && mkdir -p Android/Sdk/cmdline-tools
WORKDIR /home/${ANDROID_USER}/Android
RUN wget --quiet --output-document=android-sdk.zip https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS}_latest.zip
RUN unzip -d Sdk/cmdline-tools android-sdk.zip
RUN mv Sdk/cmdline-tools/cmdline-tools Sdk/cmdline-tools/latest
RUN rm android-sdk.zip

# Install SDK parts
RUN echo y | sdkmanager "platforms;android-${ANDROID_COMPILE_SDK}" >/dev/null
RUN echo y | sdkmanager "platform-tools" >/dev/null
RUN echo y | sdkmanager "build-tools;${ANDROID_BUILD_TOOLS}" >/dev/null
RUN echo y | sdkmanager "ndk;${ANDROID_NDK}" >/dev/null

# Workdir
RUN cd /home/${ANDROID_USER} && mkdir -p Project
WORKDIR /home/${ANDROID_USER}/Project

and here’s our .gitlab-ci.yml

stages:
  - build
  - release

  assemble:
  stage: build
  image: my-android-image
  before_script:
    - echo $CI_JOB_ID
    - echo GE_JOB_ID=$CI_JOB_ID >> assemble.env
  script:
    - ./gradlew assembleRelease
  artifacts:
    paths:
      - app/build/outputs/apk/release
    reports:
      dotenv: assemble.env

release:
  stage: release
  image: registry.gitlab.com/gitlab-org/release-cli:latest
  script:
    # This is the variable we saved in the 'before_script' section in the assemble job
    - echo "Running release for job"
    - echo $GE_JOB_ID
  # We 'need' the assemble job, otherwise we wouldn't have anything to release.
  needs:
    - job: assemble
      artifacts: true
  release:
    name: Release $CI_COMMIT_SHORT_SHA
    description: Create release $CI_COMMIT_SHORT_SHA using the release-cli
    tag_name: $CI_COMMIT_SHORT_SHA
    assets:
      links:
        - name: "app-release.apk"
          url: "https://gitlab.com/my-group-placeholder/my-project-placeholder/-/jobs/${GE_JOB_ID}/artifacts/raw/app/build/outputs/apk/release/app-release.apk"
  # Only run this job on the main branch
  only:
    - main