mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 11:31:08 +00:00
* Build the DirectAudio relay from source
The relay helper shipped as a prebuilt libdirectaudiorelay.so with no source in the
repo. Its source (The412Banner/directaudio @ 3548a4a, the commit the bundled driver
names in version.txt) now lives in tools/directaudio-relay, unmodified, and CI and
build_local.sh compile it into jniLibs the way proot is built. It shares
da_relay_proto.h with the PulseAudio sink, which is byte-identical to its own copy.
* DirectAudio relay: prime before playing, one consumer, safe buffer floors
- The output stream started before the client had its ring, so every connection
opened with a run of short callbacks: six clicks per stream on a RedMagic, even idle.
The callback now plays silence until the ring holds its target, and only a real
underrun afterwards counts.
- An underrun ends the block with a short fade and waits for the ring to refill to
target before resuming with a fade in, instead of zero-padding every short callback.
Each callback spent starved grows the target, so one stall sizes it for the next.
With a producer freezing 60 ms every 2 s: 14 underruns before, 1 after.
- During a reopen the outgoing and incoming streams could both drain the ring. Only
the promoted stream reads it now; any other plays silence.
- The ring target starts at 40 ms instead of two bursts (8 ms) and keeps what it
learned across reopens.
- The device buffer is at least two bursts; 12 ms was a single burst on devices that
grant a 20 ms legacy stream.
* PulseAudio: the classic path plays through the native-rate AAudio sink
The classic path loaded the prebuilt 0.1.5 module, which opened AAudio at the daemon's
default 44.1 kHz: Android gave it a legacy stream (20 ms bursts) and resampled, after
PulseAudio had already resampled the 48 kHz clients down. Its data callback also waited
on the daemon's IO thread, and it started at two bursts and grew only after an audible
underrun. The app's own module-aaudio-sink, built by CI into the same bundle, now
carries that path:
- The stream opens at the device's rate (48 kHz) with the buffer at two bursts or
30 ms, whichever is more, still growing a burst per underrun.
- A write that times out part way keeps the rest for the next write instead of
dropping it.
- A stream reopened after a route change asks for the rate the first one got, so the
sink keeps its format instead of unloading.
* Audio: DirectAudio sink at audio priority, deeper client buffers, no decay probing
- module-directaudio-sink's IO thread asks for nice -16 like module-aaudio-sink's, so
the client's producer keeps up under game load.
- PULSE_LATENCY_MSEC=60 for the session: libpulse clients under FEX (the Steam
client, winepulse when DirectAudio is off) keep enough queued to ride out a late
wakeup.
- BANNER_AUDIO_DIRECT_DECAY=0: the driver's decay shrinks the device buffer every
10 s of quiet until it underruns again, a click by design.
Both sit ahead of Downloads/droiddeck-env and per-game environment, which still win.
* Audio: drop the session-wide PULSE_LATENCY_MSEC
On an AYN Thor it took the Steam client's stream to 110 ms (60 ms tlength on top of the
sink's 40 ms) with no underruns to show for it, and the delay was audible. Clients pick
their own buffers again; the per-game option still sets it.
* PulseAudio sinks: wait in the poll loop, never in a blocking call
Clients' audio reaches a sink as messages its IO thread handles in pa_rtpoll_run. The AAudio
sink spent each iteration inside a blocking AAudioStream_write for up to a burst, and the
DirectAudio sink inside a futex wait for up to a burst, so a client's data sat unread and its
stream ran dry: a Chromium-shaped client (44.1 kHz float, 30 ms tlength, 10 ms minreq) had
304 underflows in 30 s on the AAudio sink, heard as crackling in the Steam client. Bursts of
20 ms (a legacy stream, an AYN Thor) made it worst.
- module-aaudio-sink writes without blocking, renders only what the device has room for, and
comes back after half a burst. 0 underflows in 30 s, with and without 8 busy cores.
- module-directaudio-sink tops the ring up on the same half-burst timer instead of sleeping
on the relay's futex.
* DirectAudio relay: vendor the source the shipped binary was built from
The import took 3548a4a, the commit the bundled driver names in version.txt, but the relay
binary that shipped came from e4727e9 on the same branch (feat/linux-relay-mic): its strings
match a build of that source exactly. 3548a4a has no --mic-fifo, so with the microphone on the
relay printed its usage and exited, and DirectAudio played nothing. This is e4727e9 as
published; the fixes made on top of 3548a4a follow in the next commit.
* DirectAudio relay: prime before playing, one consumer, safe buffer floors (on e4727e9)
The same changes as ca5d39b, carried onto the e4727e9 source: playback waits until the ring
holds its target, an underrun fades out and re-primes, starved callbacks grow the target, only
the promoted stream drains the ring, the ring target starts at 40 ms and survives reopens, and
the device buffer is at least two bursts.
* Steam client audio: DirectAudio by default
A fresh install plays the Steam client through the DirectAudio relay. Choosing Classic in the
Steam cog is stored and kept; only an install that never chose falls back to DirectAudio.
710 lines
35 KiB
YAML
710 lines
35 KiB
YAML
name: Build APK
|
|
run-name: ${{ github.event_name == 'pull_request' && format('building pr {0} on {1}', github.event.pull_request.number, github.event.pull_request.head.sha) || format('building {0} on {1}', github.ref_name, github.sha) }}
|
|
|
|
on:
|
|
push:
|
|
branches: [ main ]
|
|
paths-ignore:
|
|
- '**/*.md'
|
|
- 'LICENSE'
|
|
- '.gitignore'
|
|
pull_request:
|
|
branches: [ main ]
|
|
types: [ opened, synchronize, reopened, ready_for_review ]
|
|
paths-ignore:
|
|
- '**/*.md'
|
|
- 'LICENSE'
|
|
- '.gitignore'
|
|
workflow_dispatch:
|
|
|
|
permissions:
|
|
contents: read
|
|
pull-requests: write
|
|
|
|
concurrency:
|
|
# Replace older builds only for the same PR. Each push/dispatch gets its own group so one main
|
|
# build cannot cancel another before it signs and publishes.
|
|
group: build-${{ github.event.pull_request.number || github.run_id }}
|
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
|
|
env:
|
|
PRELOAD_CACHE_VERSION: v3
|
|
|
|
jobs:
|
|
build:
|
|
name: Build APK
|
|
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 45
|
|
outputs:
|
|
variants: ${{ steps.variants.outputs.list }}
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
|
fetch-depth: 0
|
|
|
|
- name: The packages a signed build ships as
|
|
id: variants
|
|
run: echo "list=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: No private key in the tree
|
|
run: |
|
|
set -euo pipefail
|
|
keys=$(git ls-files | grep -iE '\.(jks|keystore|p12|pfx|pk8|pem|key)$' | grep -vx 'keystore/testkey.p12' || true)
|
|
if [ -n "$keys" ]; then
|
|
echo "::error::a private key is committed - remove it from the history and make a new one: $keys"
|
|
exit 1
|
|
fi
|
|
|
|
- name: Version bumps are isolated
|
|
if: github.event_name == 'pull_request'
|
|
run: |
|
|
set -euo pipefail
|
|
git fetch --no-tags origin main
|
|
base=$(git merge-base origin/main HEAD)
|
|
diff=$(git diff -U0 "$base..HEAD" -- app/build.gradle)
|
|
grep -Eq '^[+-][[:space:]]*version(Code|Name)[[:space:]]' <<<"$diff" || exit 0
|
|
other_files=$(git diff --name-only "$base..HEAD" | grep -v '^app/build.gradle$' || true)
|
|
other_gradle=$(grep -E '^[+-]' <<<"$diff" | grep -Ev '^(---|\+\+\+)' | grep -Ev '^[+-][[:space:]]*version(Code|Name)[[:space:]]' || true)
|
|
if [[ -n "$other_files" || -n "$other_gradle" ]]; then
|
|
echo "::error::versionCode/versionName changes must be isolated in their own PR"
|
|
echo "A version PR may only change the versionCode/versionName lines in app/build.gradle." >> "$GITHUB_STEP_SUMMARY"
|
|
exit 1
|
|
fi
|
|
|
|
- name: Merge onto main, or say what conflicts
|
|
if: github.event_name == 'pull_request'
|
|
run: |
|
|
set -uo pipefail
|
|
git config user.name "ci"
|
|
git config user.email "ci@users.noreply.github.com"
|
|
git fetch --no-tags origin main
|
|
BASE=$(git rev-parse --short origin/main)
|
|
HEAD=$(git rev-parse --short HEAD)
|
|
if git merge --no-ff --no-edit origin/main >/tmp/merge.out 2>&1; then
|
|
{
|
|
echo "### Mergeable"
|
|
echo
|
|
echo "\`$HEAD\` merges cleanly onto main (\`$BASE\`). What follows was built from the merged result."
|
|
} > /tmp/pr-merge.md
|
|
cat /tmp/pr-merge.md >> "$GITHUB_STEP_SUMMARY"
|
|
exit 0
|
|
fi
|
|
FILES=$(git diff --name-only --diff-filter=U)
|
|
if [ -z "$FILES" ]; then
|
|
{
|
|
echo "### ERROR: Could not merge onto main (\`$BASE\`)"
|
|
echo
|
|
echo '```'
|
|
cat /tmp/merge.out
|
|
echo '```'
|
|
} > /tmp/pr-merge.md
|
|
cat /tmp/pr-merge.md >> "$GITHUB_STEP_SUMMARY"
|
|
echo "::error::git could not merge main: $(head -3 /tmp/merge.out | tr '\n' ' ')"
|
|
exit 1
|
|
fi
|
|
{
|
|
echo "### ERROR: Not mergeable - conflicts with main (\`$BASE\`)"
|
|
echo
|
|
echo "Files that conflict:"
|
|
echo
|
|
for f in $FILES; do echo "- \`$f\`"; done
|
|
echo
|
|
echo "Each conflicting hunk, as git leaves it: \`<<<<<<< HEAD\` is this pull request, \`>>>>>>> origin/main\` is main."
|
|
echo
|
|
for f in $FILES; do
|
|
echo "<details><summary><code>$f</code></summary>"
|
|
echo
|
|
echo '```'
|
|
awk '/^<<<<<<< /{p=1} p{print} /^>>>>>>> /{p=0}' "$f" | head -300
|
|
echo '```'
|
|
echo "</details>"
|
|
echo
|
|
done
|
|
echo "Not built: rebase or merge main into the branch and push again."
|
|
} > /tmp/pr-merge.md
|
|
cat /tmp/pr-merge.md >> "$GITHUB_STEP_SUMMARY"
|
|
git merge --abort || true
|
|
echo "::error::the pull request conflicts with main: $(echo $FILES | tr '\n' ' ')"
|
|
exit 1
|
|
|
|
- uses: actions/setup-java@v4
|
|
with:
|
|
distribution: temurin
|
|
java-version: '17'
|
|
|
|
- uses: gradle/actions/setup-gradle@v4
|
|
with:
|
|
cache-cleanup: on-success
|
|
|
|
- name: Resolve the runner's NDK
|
|
run: |
|
|
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
|
VERSION=$(sed -n 's/^Pkg.Revision *= *//p' "$NDK/source.properties")
|
|
echo "NDK_VERSION=$VERSION" >> "$GITHUB_ENV"
|
|
|
|
- name: Set the APK build label
|
|
id: build-label
|
|
env:
|
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
BUILD_REF: ${{ github.ref_name }}
|
|
BUILD_SHA: ${{ github.sha }}
|
|
run: |
|
|
if [[ -n "$(git status --porcelain)" ]]; then
|
|
tree_state=dirty
|
|
else
|
|
tree_state=clean
|
|
fi
|
|
echo "DROIDDECK_BUILD_TREE_STATE=$tree_state" >> "$GITHUB_ENV"
|
|
if [[ -n "$PR_NUMBER" ]]; then
|
|
label="pr${PR_NUMBER} at ${PR_HEAD_SHA:0:7}"
|
|
else
|
|
label="${BUILD_REF} at ${BUILD_SHA:0:7}"
|
|
fi
|
|
echo "label=$label" >> "$GITHUB_OUTPUT"
|
|
echo "DROIDDECK_SOURCE_COMMIT=${PR_HEAD_SHA:-$BUILD_SHA}" >> "$GITHUB_ENV"
|
|
echo "DROIDDECK_PR_NUMBER=${PR_NUMBER:-0}" >> "$GITHUB_ENV"
|
|
echo "Build: \`$label\`" >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
- name: Cache the preloads
|
|
id: preloads
|
|
uses: actions/cache@v4
|
|
with:
|
|
path: app/src/main/assets/linuxfs
|
|
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**') }}
|
|
|
|
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
|
|
if: steps.preloads.outputs.cache-hit != 'true'
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
sudo apt-get update -qq
|
|
sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu >/dev/null
|
|
D=app/src/main/assets/linuxfs
|
|
mkdir -p "$D"
|
|
aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \
|
|
-pthread -std=c++17 -static-libstdc++ -static-libgcc -Wl,--exclude-libs,ALL \
|
|
-o "$D/libfakeinput.so" app/src/main/cpp/fakeinput_steam.cpp -ldl
|
|
aarch64-linux-gnu-strip --strip-unneeded "$D/libfakeinput.so"
|
|
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
|
-o "$D/libblsession.so" tools/linuxfs/preload/*.c -ldl
|
|
aarch64-linux-gnu-strip --strip-unneeded "$D/libblsession.so"
|
|
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
|
-o "$D/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl
|
|
aarch64-linux-gnu-strip --strip-unneeded "$D/libblfastpath.so"
|
|
# Force-SSBS adapter, preloaded into a game's Wine only when the game asks for it.
|
|
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
|
-o "$D/libssbs.so" tools/linuxfs/ssbs/ssbs_adapter.c -ldl
|
|
aarch64-linux-gnu-strip --strip-unneeded "$D/libssbs.so"
|
|
mkdir -p "$D/usr/local/bin"
|
|
aarch64-linux-gnu-gcc -O2 -Wall -Wextra -o "$D/usr/local/bin/droiddeck-clipboard" tools/linuxfs/clipboard/clipboard.c -ldl
|
|
aarch64-linux-gnu-strip --strip-unneeded "$D/usr/local/bin/droiddeck-clipboard"
|
|
for script in tools/linuxfs/overlay/usr/local/bin/droiddeck-* tools/linuxfs/overlay/usr/local/bin/steam-compatibility; do
|
|
install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")"
|
|
done
|
|
for f in tools/linuxfs/overlay/usr/bin/* tools/linuxfs/overlay/usr/bin/steamos-polkit-helpers/*; do
|
|
[ -f "$f" ] && install -Dm644 "$f" "$D/${f#tools/linuxfs/overlay/}"
|
|
done
|
|
if [ -f tools/gamescope/release.env ]; then
|
|
. tools/gamescope/release.env
|
|
gh release download "${GAMESCOPE_TAG}" -R "${GITHUB_REPOSITORY}" -p gamescope.tzst -O gamescope.tzst
|
|
echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c -
|
|
tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D"
|
|
test -f "$D/usr/local/bin/gamescope"
|
|
rm gamescope.tzst
|
|
fi
|
|
if [ -f tools/wlroots/release.env ]; then
|
|
. tools/wlroots/release.env
|
|
gh release download "${WLROOTS_TAG}" -R "${GITHUB_REPOSITORY}" -p wlroots.tzst -O wlroots.tzst
|
|
echo "${WLROOTS_SHA256} wlroots.tzst" | sha256sum -c -
|
|
tar --use-compress-program=unzstd -xf wlroots.tzst -C "$D"
|
|
test -f "$D/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so"
|
|
rm wlroots.tzst
|
|
fi
|
|
. tools/linuxfs/uruntime.env
|
|
curl -fsSL --retry 3 -o uruntime "https://github.com/VHSgunzo/uruntime/releases/download/${URUNTIME_VERSION}/${URUNTIME_ASSET}"
|
|
echo "${URUNTIME_SHA256} uruntime" | sha256sum -c -
|
|
install -Dm644 uruntime "$D/usr/local/lib/droiddeck/uruntime"
|
|
install -Dm644 tools/linuxfs/licenses/uruntime-LICENSE "$D/usr/local/share/licenses/uruntime/LICENSE"
|
|
rm uruntime
|
|
M="$D/usr/local/lib/mangoapp"
|
|
mkdir -p "$M" mango-pkgs
|
|
grep -v '^#' tools/mangoapp/packages.txt | while read -r sum url; do
|
|
[ -n "$url" ] || continue
|
|
curl -fsSL --retry 3 -o mango-pkgs/pkg.tar.zst "$url"
|
|
echo "$sum mango-pkgs/pkg.tar.zst" | sha256sum -c -
|
|
tar --use-compress-program=unzstd -xf mango-pkgs/pkg.tar.zst -C mango-pkgs
|
|
rm mango-pkgs/pkg.tar.zst
|
|
done
|
|
install -m644 mango-pkgs/usr/bin/mangoapp "$M/mangoapp"
|
|
for so in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1; do
|
|
cp -L "mango-pkgs/usr/lib/$so" "$M/$so"
|
|
done
|
|
# Ours, not the package's: GPU memory without tracefs (tools/mangoapp/libtracefs-shim.c).
|
|
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -Wl,-soname,libtracefs.so.1 \
|
|
-o "$M/libtracefs.so.1" tools/mangoapp/libtracefs-shim.c
|
|
aarch64-linux-gnu-strip --strip-unneeded "$M/libtracefs.so.1"
|
|
install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
|
|
rm -rf mango-pkgs
|
|
ls -l "$M"
|
|
# The Windows components installer's tools (tools/msitools/packages.txt).
|
|
T="$D/usr/local/lib/droiddeck-msitools"
|
|
mkdir -p "$T" msi-pkgs
|
|
grep -v '^#' tools/msitools/packages.txt | while read -r sum url; do
|
|
[ -n "$url" ] || continue
|
|
curl -fsSL --retry 3 -o msi-pkgs/pkg.tar.xz "$url"
|
|
echo "$sum msi-pkgs/pkg.tar.xz" | sha256sum -c -
|
|
tar -xJf msi-pkgs/pkg.tar.xz -C msi-pkgs
|
|
rm msi-pkgs/pkg.tar.xz
|
|
done
|
|
install -m644 msi-pkgs/usr/bin/msiinfo "$T/msiinfo"
|
|
install -m644 msi-pkgs/usr/bin/cabextract "$T/cabextract"
|
|
for so in libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
|
|
cp -L "msi-pkgs/usr/lib/$so" "$T/$so"
|
|
done
|
|
for f in NOTICE GPL-2 GPL-3 LGPL-2.1; do
|
|
install -Dm644 "tools/msitools/licenses/$f" "$D/usr/local/share/licenses/droiddeck-msitools/$f"
|
|
done
|
|
rm -rf msi-pkgs
|
|
ls -l "$T"
|
|
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$D/usr/local/bin/droiddeck-desktop"
|
|
install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$D/usr/local/bin/droiddeck-gpu"
|
|
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$D/usr/local/bin/droiddeck-desktop-gpu"
|
|
install -Dm644 tools/linuxfs/desktop/autostart "$D/etc/xdg/labwc/autostart"
|
|
install -Dm644 tools/linuxfs/desktop/rc.xml "$D/etc/xdg/labwc/rc.xml"
|
|
install -Dm644 tools/linuxfs/desktop/panel.conf "$D/etc/xdg/lxqt/panel.conf"
|
|
install -Dm644 tools/linuxfs/desktop/firefox-droiddeck.js "$D/usr/lib/firefox/defaults/pref/droiddeck.js"
|
|
ls -lR "$D"
|
|
|
|
- name: Build the x86 preloads FEX guests load (glibc 2.31)
|
|
if: steps.preloads.outputs.cache-hit != 'true'
|
|
run: |
|
|
set -euo pipefail
|
|
docker run --rm -v "$PWD:/src" -w /src debian:bullseye bash -c '
|
|
set -euo pipefail
|
|
printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
|
|
apt-get -o Acquire::Check-Valid-Until=false update -qq
|
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends gcc g++ gcc-multilib g++-multilib binutils >/dev/null
|
|
tools/linuxfs/build-x86-preloads.sh app/src/main/assets/linuxfs
|
|
'
|
|
sudo chown -R "$(id -u):$(id -g)" app/src/main/assets/linuxfs
|
|
ls -l app/src/main/assets/linuxfs/x86_64 app/src/main/assets/linuxfs/i386
|
|
|
|
- name: Bundle the droiddeck-esync packs
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -f tools/droiddeck-esync/release.env ]; then
|
|
. tools/droiddeck-esync/release.env
|
|
gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p "$SYNC_BUNDLE_ASSET" -O sync-bundle.tzst
|
|
echo "$SYNC_BUNDLE_SHA256 sync-bundle.tzst" | sha256sum -c -
|
|
mkdir -p app/src/main/assets/droiddeck-esync
|
|
tar --use-compress-program=unzstd -xf sync-bundle.tzst -C app/src/main/assets/droiddeck-esync
|
|
rm sync-bundle.tzst
|
|
gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p index.json -O sync-index.json
|
|
revoked=$(python3 -c 'import json, sys; print(" ".join(p["id"] for p in json.load(open(sys.argv[1]))["packs"] if p.get("revoked") is True))' sync-index.json)
|
|
for id in $revoked; do
|
|
if [ -e "app/src/main/assets/droiddeck-esync/packs/$id.tzst" ]; then
|
|
echo "::warning::$SYNC_BUNDLE_ASSET carries revoked pack $id; it is left out of the APK (point tools/droiddeck-esync/release.env at a newer bundle)"
|
|
rm -f "app/src/main/assets/droiddeck-esync/packs/$id.tzst"
|
|
fi
|
|
done
|
|
rm sync-index.json
|
|
test -f app/src/main/assets/droiddeck-esync/index.json
|
|
test -f app/src/main/assets/droiddeck-esync/index.json.sig
|
|
ls -lR app/src/main/assets/droiddeck-esync
|
|
fi
|
|
|
|
- name: Cache PulseAudio 13.0 source
|
|
id: pa-src
|
|
uses: actions/cache@v4
|
|
with:
|
|
path: pa13
|
|
key: pulseaudio-src-13.0
|
|
- name: Fetch PulseAudio 13.0 source
|
|
if: steps.pa-src.outputs.cache-hit != 'true'
|
|
run: |
|
|
set -euo pipefail
|
|
curl -fsSL -o pa.tar.gz https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
|
|
mkdir -p pa13 && tar -xzf pa.tar.gz -C pa13 --strip-components=1 && rm pa.tar.gz
|
|
- name: Build the AAudio sink into the audio bundle
|
|
run: |
|
|
set -euo pipefail
|
|
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
|
tools/aaudio-sink/build.sh "$PWD/pa13" "$PWD/sink-out"
|
|
rm -rf pa-bundle && mkdir -p pa-bundle
|
|
tar --use-compress-program=unzstd -xf app/src/main/assets/pulseaudio.tzst -C pa-bundle
|
|
test ! -f pa-bundle/modules/arm64/module-aaudio-sink.so
|
|
install -m755 sink-out/module-aaudio-sink.so pa-bundle/modules/arm64/module-aaudio-sink.so
|
|
install -m755 sink-out/module-directaudio-sink.so pa-bundle/modules/arm64/module-directaudio-sink.so
|
|
(cd pa-bundle && tar --use-compress-program='zstd -19' -cf ../pulseaudio.tzst .)
|
|
mv pulseaudio.tzst app/src/main/assets/pulseaudio.tzst
|
|
tar --use-compress-program=unzstd -tf app/src/main/assets/pulseaudio.tzst | grep -c '\.so$'
|
|
rm -rf pa-bundle sink-out
|
|
- name: Build the DirectAudio relay
|
|
run: |
|
|
set -euo pipefail
|
|
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
|
tools/directaudio-relay/build.sh app/src/main/jniLibs/arm64-v8a
|
|
|
|
- name: Cache proot
|
|
id: proot
|
|
uses: actions/cache@v4
|
|
with:
|
|
path: |
|
|
app/src/main/jniLibs/arm64-v8a/libproot.so
|
|
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
|
|
key: proot-${{ env.NDK_VERSION }}-${{ hashFiles('tools/proot/**') }}
|
|
- name: Build proot
|
|
if: steps.proot.outputs.cache-hit != 'true'
|
|
run: |
|
|
set -euo pipefail
|
|
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
|
tools/proot/build.sh app/src/main/jniLibs/arm64-v8a
|
|
|
|
- name: Check the preloads
|
|
run: |
|
|
set -euo pipefail
|
|
D=app/src/main/assets/linuxfs
|
|
NEED=$(readelf -d "$D/libfakeinput.so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p')
|
|
for bad in libstdc++.so.6 libgcc_s.so.1; do
|
|
if printf '%s\n' "$NEED" | grep -qx "$bad"; then
|
|
echo "::error::libfakeinput.so links $bad - the C++ runtime must stay static"; exit 1
|
|
fi
|
|
done
|
|
syms() { readelf -Ws "$1" | awk '$4 == "FUNC" && $5 == "GLOBAL" {sub(/@.*/, "", $8); print $8}'; }
|
|
FAKE=$(syms "$D/libfakeinput.so")
|
|
for sym in open openat ioctl read close poll ppoll select stat fstat access scandir; do
|
|
grep -qx "$sym" <<<"$FAKE" || { echo "::error::libfakeinput.so does not export $sym"; exit 1; }
|
|
done
|
|
# The static C++ runtime stays private: every session process preloads this library,
|
|
# and an exported personality routine takes over other programs' unwinding (shadPS4
|
|
# aborted in pthread_exit).
|
|
if grep -qxE '__gxx_personality_v0|__cxa_throw|_Unwind_Resume' <<<"$FAKE"; then
|
|
echo "::error::libfakeinput.so exports the C++ runtime"; exit 1
|
|
fi
|
|
SESSION=$(syms "$D/libblsession.so")
|
|
for sym in socket bind getsockname setsockopt statfs statvfs syscall shm_open shm_unlink mmap mmap64 munmap drmPrimeFDToHandle drmCloseBufferHandle; do
|
|
grep -qx "$sym" <<<"$SESSION" || { echo "::error::libblsession.so does not export $sym"; exit 1; }
|
|
done
|
|
# Zink's GEM_CLOSE is caught at ioctl(); an exported drmIoctl made NMS replay old frames.
|
|
if grep -qx drmIoctl <<<"$SESSION"; then
|
|
echo "::error::libblsession.so exports drmIoctl"; exit 1
|
|
fi
|
|
SSBS=$(syms "$D/libssbs.so")
|
|
for sym in sigaction signal bsd_signal sysv_signal; do
|
|
grep -qx "$sym" <<<"$SSBS" || { echo "::error::libssbs.so does not export $sym"; exit 1; }
|
|
done
|
|
test -f "$D/usr/local/bin/droiddeck-session"
|
|
test -f "$D/usr/local/bin/droiddeck-proton-extra"
|
|
test -f "$D/usr/local/bin/droiddeck-msi-install"
|
|
for f in msiinfo cabextract libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
|
|
test -f "$D/usr/local/lib/droiddeck-msitools/$f" || { echo "::error::droiddeck-msitools/$f missing"; exit 1; }
|
|
done
|
|
|
|
- name: Cache the CMake build tree
|
|
uses: actions/cache@v4
|
|
with:
|
|
path: |
|
|
app/.cxx
|
|
app/build/intermediates/cxx
|
|
key: cxx-${{ env.NDK_VERSION }}-${{ hashFiles('app/src/main/cpp/**') }}
|
|
restore-keys: |
|
|
cxx-${{ env.NDK_VERSION }}-
|
|
|
|
- name: Build
|
|
id: build
|
|
env:
|
|
DROIDDECK_BUILD_LABEL: ${{ steps.build-label.outputs.label }}
|
|
run: |
|
|
set -o pipefail
|
|
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
|
|
python3 tools/release/check_session_assets.py app/build/outputs/apk/release/app-release.apk
|
|
|
|
- name: Steam import and game environment tests
|
|
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
|
|
|
|
- name: Unit tests
|
|
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
|
|
|
|
- name: Check the native dependency closure
|
|
run: |
|
|
set -euo pipefail
|
|
APK=$(find app/build/outputs/apk -name '*.apk' | head -1)
|
|
WORK=$(mktemp -d)
|
|
unzip -q "$APK" 'lib/arm64-v8a/*' -d "$WORK"
|
|
cd "$WORK/lib/arm64-v8a"
|
|
SYSTEM="libc.so libm.so libdl.so liblog.so libandroid.so libz.so libvulkan.so libstdc++.so
|
|
libGLESv2.so libEGL.so libnativewindow.so libjnigraphics.so libaaudio.so
|
|
libOpenSLES.so libmediandk.so libcamera2ndk.so libsync.so libneuralnetworks.so"
|
|
fail=0
|
|
for so in *.so; do
|
|
for need in $(readelf -d "$so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p'); do
|
|
[ -f "$need" ] && continue
|
|
case " $(echo $SYSTEM) " in *" $need "*) continue ;; esac
|
|
echo "::error::$so needs $need, which is neither in the apk nor a platform library"
|
|
fail=1
|
|
done
|
|
done
|
|
exit $fail
|
|
|
|
- name: Sign with the testkey (v1 + v2 + v3)
|
|
run: |
|
|
set -euo pipefail
|
|
APK=$(find app/build/outputs/apk -name '*.apk' | head -1)
|
|
TOOLS=$(find "$ANDROID_HOME/build-tools" -maxdepth 1 -type d | sort -V | tail -1)
|
|
"$TOOLS/zipalign" -p -f 4 "$APK" "$APK.aligned"
|
|
"$TOOLS/apksigner" sign \
|
|
--ks keystore/testkey.p12 --ks-type PKCS12 --ks-pass pass:android \
|
|
--ks-key-alias testkey --key-pass pass:android \
|
|
--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true \
|
|
--out "$APK" "$APK.aligned"
|
|
rm -f "$APK.aligned" "$APK.idsig"
|
|
|
|
- name: Check the signature
|
|
run: |
|
|
set -euo pipefail
|
|
APK=$(find app/build/outputs/apk -name '*.apk' | head -1)
|
|
APKSIGNER=$(find "$ANDROID_HOME/build-tools" -name apksigner | sort -V | tail -1)
|
|
"$APKSIGNER" verify --min-sdk-version 21 --verbose --print-certs "$APK" | tee /tmp/sig.txt
|
|
unzip -l "$APK" | grep -qE 'META-INF/.*\.(SF|RSA|DSA)$' \
|
|
|| { echo "::error::apk carries no JAR signature files"; exit 1; }
|
|
for scheme in "v1 scheme (JAR signing): true" "v2 scheme (APK Signature Scheme v2): true" \
|
|
"v3 scheme (APK Signature Scheme v3): true"; do
|
|
grep -qF "$scheme" /tmp/sig.txt || { echo "::error::apk is not signed with $scheme"; exit 1; }
|
|
done
|
|
grep -qi "CN=Android, OU=Android, O=Android" /tmp/sig.txt \
|
|
|| { echo "::error::apk is not signed with the AOSP testkey"; exit 1; }
|
|
|
|
- name: Report on the pull request
|
|
if: always() && github.event_name == 'pull_request'
|
|
continue-on-error: true
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
PR: ${{ github.event.pull_request.number }}
|
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
BUILD_OUTCOME: ${{ steps.build.outcome }}
|
|
SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
|
run: |
|
|
set -uo pipefail
|
|
MARK="<!-- droiddeck-pr-build -->"
|
|
{
|
|
echo "$MARK"
|
|
echo "## Build of this pull request"
|
|
echo
|
|
[ -f /tmp/pr-merge.md ] && cat /tmp/pr-merge.md
|
|
echo
|
|
case "$BUILD_OUTCOME" in
|
|
success)
|
|
echo "### Build passed"
|
|
echo
|
|
echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key."
|
|
echo
|
|
if [ "$SAME_REPO" = true ]; then
|
|
echo "A release-signed tester build that installs over Stable or Preview is being published now and will appear in DroidDeck's **Test builds** list. Label the PR \`no-test-build\` to skip that."
|
|
else
|
|
echo "**Maintainer:** add the \`publish-test\` label to publish a release-signed tester build that installs over Stable or Preview and appears in DroidDeck's **Test builds** list. The label is removed automatically after publishing."
|
|
fi
|
|
echo
|
|
echo "Fallback: [open **PR Release Publish**]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/workflows/pr-release-publish.yml) on \`main\` and enter PR \`$PR\`."
|
|
;;
|
|
failure)
|
|
echo "### ERROR: Build failed"
|
|
echo
|
|
echo "The compiler's errors ([full log]($RUN_URL)):"
|
|
echo
|
|
echo '```'
|
|
{ grep -E '^e: |^w: .*error|error:|CMake Error|fatal error|ninja: error|FAILED|What went wrong|Execution failed for task|Unresolved reference|Type mismatch|Unresolved' /tmp/build.log 2>/dev/null \
|
|
| sed -e 's#file:///home/runner/work/[^/]*/[^/]*/##' -e 's#/home/runner/work/[^/]*/[^/]*/##' \
|
|
| grep -v '^w: ' | awk '!seen[$0]++' | head -80; } || true
|
|
echo '```'
|
|
echo
|
|
echo "<details><summary>What went wrong (Gradle)</summary>"
|
|
echo
|
|
echo '```'
|
|
awk '/^\* What went wrong/{p=1} /^\* Try:/{p=0} p{print}' /tmp/build.log 2>/dev/null | head -60
|
|
echo '```'
|
|
echo "</details>"
|
|
;;
|
|
skipped|cancelled)
|
|
echo "### Not built"
|
|
echo
|
|
echo "The build did not run (see above, or [the run]($RUN_URL))."
|
|
;;
|
|
esac
|
|
echo
|
|
} > /tmp/pr-report.md
|
|
cat /tmp/pr-report.md >> "$GITHUB_STEP_SUMMARY"
|
|
ID=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARK\")) | .id" | head -1 || true)
|
|
if [ -n "$ID" ]; then
|
|
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${ID}" -F body=@/tmp/pr-report.md >/dev/null
|
|
else
|
|
gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" -F body=@/tmp/pr-report.md >/dev/null
|
|
fi
|
|
|
|
- uses: actions/upload-artifact@v4
|
|
with:
|
|
name: droiddeck-apk
|
|
path: app/build/outputs/apk/release/*.apk
|
|
if-no-files-found: error
|
|
retention-days: 30
|
|
compression-level: 0
|
|
|
|
sign:
|
|
needs: build
|
|
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
|
permissions:
|
|
contents: read
|
|
actions: read
|
|
uses: ./.github/workflows/sign.yml
|
|
with:
|
|
environment: signing-main
|
|
variants: ${{ needs.build.outputs.variants }}
|
|
secrets: inherit
|
|
|
|
# Each signed main build as a prerelease of the public Droid-Deck/DroidDeck-CI (main-<sha>, the
|
|
# last KEEP kept), where anyone can download it without a GitHub account, then a post in the
|
|
# Discord's builds channel linking to it. The release is made with a token of the org's
|
|
# "DroidDeck CI Publisher" app (installed on DroidDeck-CI only, Contents: write): its ID in the
|
|
# DROIDDECK_CI_APP_ID variable, its private key in the DROIDDECK_CI_APP_KEY secret.
|
|
# DISCORD_BUILDS_WEBHOOK is the channel's webhook URL.
|
|
announce:
|
|
needs: [build, sign]
|
|
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 10
|
|
permissions:
|
|
contents: read
|
|
pull-requests: read
|
|
env:
|
|
CI_REPO: Droid-Deck/DroidDeck-CI
|
|
KEEP: 10
|
|
steps:
|
|
- uses: actions/download-artifact@v4
|
|
with:
|
|
pattern: droiddeck-signed-*
|
|
merge-multiple: true
|
|
path: signed
|
|
|
|
- uses: actions/create-github-app-token@v2
|
|
id: ci-token
|
|
with:
|
|
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
|
|
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
|
|
owner: Droid-Deck
|
|
repositories: DroidDeck-CI
|
|
|
|
# What this build is, for the top of the release notes: the merged PR's title and the first
|
|
# paragraph of its description, or the commit's subject for a push made without a PR.
|
|
- name: Describe the change
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \
|
|
--jq '[.[] | select(.merged_at != null and .base.ref == "main")] | first // empty')
|
|
if [ -n "$pr" ]; then
|
|
number=$(jq -r .number <<<"$pr")
|
|
title=$(jq -r .title <<<"$pr")
|
|
merged=$(jq -r '.merged_at[:10]' <<<"$pr")
|
|
summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } |
|
|
awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400)
|
|
{
|
|
echo "**Merged to main** - [PR #$number]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$number): $title (merged $merged)"
|
|
if [ -n "$summary" ]; then
|
|
echo
|
|
echo "$summary"
|
|
fi
|
|
} > notes-head.md
|
|
else
|
|
subject=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA" --jq '.commit.message | split("\n")[0]')
|
|
echo "**Pushed to main** without a PR - $subject" > notes-head.md
|
|
fi
|
|
cat notes-head.md
|
|
|
|
- name: Publish to DroidDeck-CI
|
|
id: publish
|
|
env:
|
|
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
tag=main-${GITHUB_SHA::7}
|
|
cat signed/SHA256SUMS-*.txt > signed/SHA256SUMS.txt
|
|
rm signed/SHA256SUMS-*.txt
|
|
notes="$(cat notes-head.md)
|
|
|
|
Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - in main, not yet in a DroidDeck release; for testing.
|
|
Commit: $GITHUB_SHA
|
|
VersionCode: $(sed -n 's/^ *versionCode \([0-9][0-9]*\)$/\1/p' app/build.gradle)
|
|
Each apk installs over a DroidDeck release and keeps its data. \`standard\` is the normal app; the others are the same app under another package name, installed beside it.
|
|
|
|
SHA-256:
|
|
\`\`\`
|
|
$(cat signed/SHA256SUMS.txt)
|
|
\`\`\`"
|
|
gh release delete "$tag" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null || true
|
|
gh release create "$tag" -R "$CI_REPO" --target main --prerelease \
|
|
--title "main ${GITHUB_SHA::7}" --notes "$notes" signed/*
|
|
# Keep the newest main builds by publication time. GitHub App releases can share createdAt.
|
|
gh release list -R "$CI_REPO" -L 200 --json tagName,publishedAt \
|
|
--jq '[.[] | select(.tagName | startswith("main-"))] | sort_by(.publishedAt) | reverse | .['"$KEEP"':][] | .tagName' |
|
|
while IFS= read -r old; do
|
|
# Never remove this run's release, even if the API returns an unexpected ordering.
|
|
if [ "$old" = "$tag" ]; then
|
|
continue
|
|
fi
|
|
# Another main publish may have removed the same old release concurrently.
|
|
if ! gh release delete "$old" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null; then
|
|
if gh release view "$old" -R "$CI_REPO" >/dev/null 2>&1; then
|
|
echo "::error::Could not delete old release $old"
|
|
exit 1
|
|
fi
|
|
fi
|
|
done
|
|
published=$(gh release view "$tag" -R "$CI_REPO" --json tagName,assets)
|
|
jq -e --arg tag "$tag" '.tagName == $tag and (.assets | length) > 0' <<< "$published" >/dev/null
|
|
url="$GITHUB_SERVER_URL/$CI_REPO/releases/tag/$tag"
|
|
apk=$(cd signed && ls DroidDeck-*.apk | awk 'length(a) == 0 || length($0) < length(a) { a = $0 } END { print a }')
|
|
echo "release=$url" >> "$GITHUB_OUTPUT"
|
|
echo "apk=$GITHUB_SERVER_URL/$CI_REPO/releases/download/$tag/$apk" >> "$GITHUB_OUTPUT"
|
|
echo "Published $url"
|
|
|
|
- name: Post to Discord
|
|
env:
|
|
WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
RELEASE: ${{ steps.publish.outputs.release }}
|
|
APK: ${{ steps.publish.outputs.apk }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -z "$WEBHOOK" ]; then
|
|
echo "::notice::No DISCORD_BUILDS_WEBHOOK secret; nothing posted"
|
|
exit 0
|
|
fi
|
|
commit=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA")
|
|
subject=$(jq -r '.commit.message | split("\n")[0]' <<<"$commit")
|
|
# A merge commit's subject is "Merge pull request #N from ..."; its body's first line is
|
|
# the PR's title, which says more.
|
|
title=$(jq -r '.commit.message | split("\n") | map(select(length > 0)) | .[1] // ""' <<<"$commit")
|
|
pr=$(sed -n 's/^Merge pull request #\([0-9]*\).*/\1/p' <<<"$subject")
|
|
if [ -n "$pr" ]; then
|
|
what="[#$pr]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$pr): ${title:-$subject}"
|
|
else
|
|
what="[\`${GITHUB_SHA::7}\`]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA): $subject"
|
|
fi
|
|
get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE")
|
|
content=$(printf '**New signed build** `main-%s`\n%s\n%s' "${GITHUB_SHA::7}" "$what" "$get")
|
|
jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' |
|
|
curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null
|
|
echo "Posted: $content"
|
|
|
|
catalog:
|
|
needs: announce
|
|
if: ${{ always() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && needs.announce.result != 'skipped' }}
|
|
uses: ./.github/workflows/update-catalog.yml
|
|
secrets: inherit
|