Automating Android App Builds

Goal: Get a compiled apk of AsteroidOS Sync from the latest git commit.

I wanted to do this without untracked software clutter on my main machine, so I decided to do it in a systemd-nspawn container on my home server. The container and build turned out better than I expected, so I went ahead with automating the whole setup to check daily for new commits, building the app if there is a new commit, and dumping it in a folder outside of the container.

Setting up systemd-nspawn

Arch makes this step super easy with the arch-install-scripts package:

- pacstrap -icd container/ base --ignore linux base-devel
  • -i: Avoid auto-confirmation of package selections
  • -c: Use the package cache on the host rather than the target
  • -d: Allow installation to a non-mountpoint directory
  • container/: Path to your desired container location
  • base --ignore linux base-devel: Install the base and base-devel groups, ignoring the linux package. The kernel is not necessary inside this container, so might as well save the bandwidth

This will get you a container with the latest packages ready to spin up. After that, all you need to do is:

- systemd-nspawn -bD container/

This will boot the container and leave you at a login prompt. For Arch, root will be passwordless. Start here, and configure your new container for what you need. For my setup, I created an unprivileged user, added my ~/.bashrc and ~/.vimrc, and installed the android-sdk package from the AUR.

Next, we need to automate bringing the new container up with a systemd service. Easiest way to get a service ready for a systemd-nspawn is to use the existing systemd-nspawn@.service, and tweak it for this specific use. To get a copy of this unit and start editing it right away, run systemctl edit --full systemd-nspawn@containername.service. This is the end product of my unit:

# /etc/systemd/system/systemd-nspawn@asteroid.service
#  This file is part of systemd.
#  systemd is free software; you can redistribute it and/or modify it
#  under the terms of the GNU Lesser General Public License as published by
#  the Free Software Foundation; either version 2.1 of the License, or
#  (at your option) any later version.

Description=Container %i

ExecStart=/usr/bin/systemd-nspawn --quiet --keep-unit --boot --link-journal=try-guest -U --settings=override --machine=%i -D /storage/containers/asteroid/sync-app/ --bind=/storage/containers/asteroid/output:/home/thurstylark/output

# Enforce a strict device policy, similar to the one nspawn configures
# when it allocates its own scope unit. Make sure to keep these
# policies in sync if you change them!
DeviceAllow=/dev/net/tun rwm
DeviceAllow=char-pts rw

# nspawn itself needs access to /dev/loop-control and /dev/loop, to
# implement the --image= option. Add these here, too.
DeviceAllow=/dev/loop-control rw
DeviceAllow=block-loop rw
DeviceAllow=block-blkext rw


The only things changed from the original are all in ExecStart=:

  • -D /storage/containers/asteroid/sync-app/: Boot the nspawn from a directory instead of from an image, and the path to that directory
  • --bind=/storage/containers/asteroid/output:/home/thurstylark/output: Create a bind mount between the host and the container. The format is /host/dir:/container/dir where /container/dir is specified with / at the root of the container, not of the host.
  • Removed --network-veth to use the networking from the host instead of creating a virtual ethernet link.

Check the systemd-nspawn manpage for more info.

To start your container, run systemctl start systemd-nspawn@containername.service. Confirm it’s running with machinectl list and systemctl status systemd-nspawn@containername.service.


Interacting With Your New Container

When your systemd-nspawn is booted, most interaction is done using machinectl(1). I will only be covering what’s necessary for this setup. Check machinectl’s manpage for more detailed info.

To get a shell:

# machinectl shell user@containername

This will bypass the login prompt, and start user’s shell. If no user is specified, you will be logged in as root.

The actual building is done by a script in the container. This means we need either a) a way to execute that script from outside the container, or b) put the script on a timer within the container. Since I don’t want the container running the whole time, I opted for option A. This allows the machine to be started and stopped as necessary.

To execute the script from outside the container:

# machinectl shell user@containername /path/to/script

Note: This path is relative to the root of the container, not of the host.

All that’s left is to make a service unit for this command. Here’s how my unit stands at the time of writing:

# /etc/systemd/system/build-aos-sync.service
Description=Build latest commit of AsteroidOS Sync

ExecStart=/usr/bin/machinectl shell thurstylark@asteroid /home/thurstylark/

This unit is set up to boot our container automatically by using Requires= and After=. This way, we don’t have to manage how our container is started. This also enables us to manually trigger a build by starting this unit.

The last part of the actual automation is done with a timer for our new service. It doesn’t have to be super complicated, but you can tweak it how you like it:

# /etc/systemd/system/build-aos-sync.timer
Description=Timer for automated AsteroidOS Sync build



This timer is set for OnCalendar=daily, which will trigger every day at midnight. When the timer triggers, it will start our service, which will start our container if it’s not already started, then it will run our build script. More options for this timer can be found in the systemd.timer manpage.

Build Script

The last peice of the puzzle for this is to actually compile the app in question. Before any of this can be done or tested, your Android build environment should be set up. Check the Android Building page for more info.

Here’s the script I ended up with:



build_app() {
	# Reference:
	cd $project_root
	git remote update
	if [[ "$(git rev-parse @)" == "$(git rev-parse @{u})" ]]; then
		echo "App up to date." >&2
		exit 0
		git pull origin master
		rm -r app/src/main/res/values-ca-rES@valencia/
		ANDROID_HOME=/opt/android-sdk ./gradlew assembleDebug

pkgver() {
	cd $project_root
	printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"

copy_output() {
	cp ${output} ${bind_mount}/${pkgname}-$(pkgver).apk


This script is not incredibly intelligent, but it gets the job done. One thing to point out is the test on line 11. This if statement checks whether our local copy of the git repo is up to date. If it’s up to date, the script exits with a little message. This way we are only building if there are changes to be built.

I also use copy_output() to rename the resulting apk with the revision number and short commit id (compiled using pkgver()) for clarity.

Thurstylark's Wiki

Half brain dump, half documentation practice.

Back when I thought I'd be using AsteroidOS all the time

Last Modified: 2021-11-21