Skip to content

fix

fix #8

Workflow file for this run

name: Dev Build Android APK
# Dev build: same build steps as build-android.yml (triggered on `build`
# branch), but:
# - triggers on `dev` branch pushes + manual dispatch
# - NEVER publishes a GitHub Release
# - ALWAYS uploads APKs as GitHub Actions artifacts (for debug)
on:
push:
branches: [ dev ]
workflow_dispatch:
# GitHub Actions already skips workflows when the commit message contains
# [skip ci], [ci skip], [no ci], [skip actions], or [actions skip].
# The job-level `if` below is an explicit, belt-and-suspenders guard.
jobs:
build:
name: Build electerm Android APK (dev)
runs-on: ubuntu-latest
if: ${{ !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[ci skip]') && !contains(github.event.head_commit.message, '[no ci]') && !contains(github.event.head_commit.message, '[skip actions]') && !contains(github.event.head_commit.message, '[actions skip]') }}
permissions:
contents: read
environment: build
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Set up Java (JDK 21)
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Accept Android SDK licenses
run: yes | sdkmanager --licenses || true
- name: Install required Android SDK packages
run: sdkmanager "platforms;android-36" "build-tools;36.0.0"
- name: ci init
run: npm config set legacy-peer-deps true
- name: Install root dependencies (electerm + build tooling)
run: npm i
- name: Install Capacitor dependencies
run: npm --prefix build/android install
- name: Build web frontend + Node.js backend bundle
env:
# JWT secret for the on-device electerm server. Baked into the APK
# at build time by build/android/build.mjs. Must be set in the
# repository GitHub Actions secrets:
# gh secret set SERVER_SECRET --body "$(openssl rand -hex 32)"
SERVER_SECRET: ${{ secrets.SERVER_SECRET }}
run: npm run build:android
- name: Add Android platform (first run only)
run: cd build/android && [ -d android ] || npx cap add android
- name: Sync Capacitor (copies web assets + native plugins)
run: cd build/android && npx cap sync android
- name: Verify FileSharer native plugin is registered
# The in-app "download to Downloads" flow calls the native FileSharer
# plugin through the Capacitor bridge. If `cap sync` doesn't register
# it (missing dep, sync failure), the JS side silently falls back to
# the web implementation whose anchor-click download is a no-op inside
# Android WebView — downloads appear broken with no error. Fail fast.
run: |
PLUGINS_JSON=build/android/android/app/src/main/assets/capacitor.plugins.json
if [ ! -f "$PLUGINS_JSON" ]; then
echo "::error::capacitor.plugins.json not found at $PLUGINS_JSON"
exit 1
fi
grep -q '"FileSharer"' "$PLUGINS_JSON" || {
echo "::error::FileSharer plugin NOT registered in $PLUGINS_JSON"
cat "$PLUGINS_JSON"
exit 1
}
echo "FileSharer plugin registered OK"
- name: Ensure nodejs-mobile gradle variable is defined
# @capawesome/capacitor-nodejs expects $nodejsMobileVersion to be set.
# Append on its own line (variables.gradle has no trailing newline, so a
# plain `echo >>` would concatenate onto the closing `}` and break Gradle).
run: |
cd build/android/android
grep -q "nodejsMobileVersion" variables.gradle || \
printf '\next.nodejsMobileVersion = "18.20.4-capawesome.1"\n' >> variables.gradle
- name: Apply icon / splash overlay
run: |
# AndroidManifest.xml must go at app/src/main/, NOT inside res/.
# Copy the electerm manifest (with icon, theme, network config) to
# the correct location, overwriting Capacitor's default.
MANIFEST_SRC=build/android/res-overlay/AndroidManifest.xml
MANIFEST_DST=build/android/android/app/src/main/AndroidManifest.xml
if [ -f "$MANIFEST_SRC" ]; then
cp "$MANIFEST_SRC" "$MANIFEST_DST"
echo "Overlayed AndroidManifest.xml -> $MANIFEST_DST"
fi
# Custom MainActivity.java: overrides Capacitor's BridgeActivity to
# opt out of edge-to-edge (setDecorFitsSystemWindows(true)). Without
# this, on Android 15+ (Pixel 9, etc.) the WebView content extends
# under the status bar and the tab bar is unclickable. The Java source
# lives under res-overlay/java/ and must go to the Gradle source tree
# (app/src/main/java/), NOT to res/.
JAVA_SRC=build/android/res-overlay/java
JAVA_DST=build/android/android/app/src/main/java
if [ -d "$JAVA_SRC" ]; then
mkdir -p "$JAVA_DST"
cp -r "$JAVA_SRC/." "$JAVA_DST/"
echo "Overlayed MainActivity.java -> $JAVA_DST"
fi
# Copy all resource dirs (drawable, mipmap-*, values, xml) into res/.
# Using overlay/. merges directory contents into the destination
# without creating extra nesting levels.
cp -r build/android/res-overlay/. build/android/android/app/src/main/res/
# The bulk cp above also copies res-overlay/java/ into res/java/ —
# Java sources must NOT live inside res/. Remove the stray copy.
rm -rf build/android/android/app/src/main/res/java
# Remove Capacitor's default resources that conflict with / shadow
# the electerm overlay. These are generated by `cap add android` and
# must be deleted AFTER the overlay copy:
# drawable-v24/ic_launcher_foreground.xml — vector foreground that
# takes precedence over our density-specific foreground PNGs
# on API 24+, showing the generic Capacitor logo instead of electerm.
# values/ic_launcher_background.xml — same color name as our
# colors-electerm.xml → duplicate-resource build error.
# drawable*/splash.png — Capacitor ships splash.png in drawable/
# AND in every density × orientation bucket (drawable-land-hdpi,
# drawable-port-xxxhdpi, …). Our splash is drawable/splash.xml
# (a layer-list). Android resource resolution prefers a
# density/orientation-specific splash.png over the default-density
# splash.xml, so on high-density phones (e.g. Huawei Mate 40 Pro
# = xxxhdpi portrait) the Capacitor plug icon was shown instead
# of the electerm splash. Remove ALL of them.
# NOTE: ic_launcher_round.* are NOT removed — the overlay provides
# electerm round icons (referenced by android:roundIcon) that
# overwrite Capacitor's defaults during the copy above.
RES=build/android/android/app/src/main/res
rm -f "$RES/drawable-v24/ic_launcher_foreground.xml"
rm -f "$RES/values/ic_launcher_background.xml"
find "$RES" -type f -name 'splash.png' -path '*/drawable*' -delete
- name: Configure per-ABI APK splits
# Produce one APK per architecture (arm64-v8a, armeabi-v7a, x86_64)
# plus a universal build, instead of a single ~160MB universal blob.
# The second `android { }` block is cumulative with Capacitor's
# template app/build.gradle; output renaming is done post-build in
# shell to avoid AGP-version-specific output-name APIs.
run: |
GRADLE=build/android/android/app/build.gradle
grep -q 'splits' "$GRADLE" || printf '%s\n' \
'' \
'// electerm: split per ABI (injected by CI)' \
'android {' \
' splits {' \
' abi {' \
' enable true' \
' reset()' \
" include 'arm64-v8a', 'armeabi-v7a', 'x86_64'" \
' universalApk true' \
' }' \
' }' \
'}' >> "$GRADLE"
tail -n 15 "$GRADLE"
- name: Cache Gradle distribution
# The Gradle wrapper downloads gradle-8.14.3-all.zip (~150 MB) from
# services.gradle.org on every run. If that download fails with a
# transient network error (e.g. "Unexpected end of file from server"),
# the entire build fails. Caching the extracted distribution avoids
# the download entirely on subsequent runs.
uses: actions/cache@v4
with:
path: ~/.gradle/wrapper/dists
key: gradle-wrapper-${{ runner.os }}-${{ hashFiles('build/android/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: |
gradle-wrapper-${{ runner.os }}-
- name: Build debug APK
# Retry up to 3 times to handle transient network errors when
# downloading the Gradle distribution for the first time (i.e. cache miss).
run: |
cd build/android/android && chmod +x gradlew
for i in 1 2 3; do
./gradlew assembleDebug && break
if [ "$i" -eq 3 ]; then
echo "::error::Build failed after 3 attempts"
exit 1
fi
echo "Attempt $i failed, retrying in 15s…"
sleep 15
done
- name: Build release APK (signed with persistent keystore)
env:
ANDROID_RELEASE_KEYSTORE_BASE64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_BASE64 }}
ANDROID_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }}
ANDROID_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
ANDROID_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
run: |
cd build/android/android
KEYSTORE="$PWD/app/release.keystore"
printf '%s' "$ANDROID_RELEASE_KEYSTORE_BASE64" | base64 --decode > "$KEYSTORE"
keytool -list -keystore "$KEYSTORE" -storepass "$ANDROID_RELEASE_KEYSTORE_PASSWORD"
./gradlew assembleRelease \
-Pandroid.injected.signing.store.file="$KEYSTORE" \
-Pandroid.injected.signing.store.password="$ANDROID_RELEASE_KEYSTORE_PASSWORD" \
-Pandroid.injected.signing.key.alias="$ANDROID_RELEASE_KEY_ALIAS" \
-Pandroid.injected.signing.key.password="$ANDROID_RELEASE_KEY_PASSWORD"
- name: Rename APKs to electerm-android-{arch}-{version}.apk
# Version comes from package.json. Each per-ABI APK is renamed to
# electerm-android-{arch}-{version}.apk (release) /
# electerm-android-{arch}-{version}-debug.apk (debug). The {arch} is
# parsed from gradle's per-ABI output filename; falls back to
# "universal" for the universal build (or if splits are off).
id: rename
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
APKROOT=build/android/android/app/build/outputs/apk
mkdir -p "$APKROOT/named"
for variant in debug release; do
dir="$APKROOT/$variant"
[ -d "$dir" ] || continue
for apk in "$dir"/*.apk; do
[ -f "$apk" ] || continue
name=$(basename "$apk")
arch=$(printf '%s' "$name" | grep -oE 'arm64-v8a|armeabi-v7a|x86_64|universal' | head -1)
[ -z "$arch" ] && arch=universal
if [ "$variant" = release ]; then
out="electerm-android-$arch-$VERSION.apk"
else
out="electerm-android-$arch-$VERSION-debug.apk"
fi
cp "$apk" "$APKROOT/named/$out"
echo " $variant/$name -> named/$out"
done
done
echo "=== named APKs ==="
ls -la "$APKROOT/named/"
- name: Upload APKs (artifact)
uses: actions/upload-artifact@v4
with:
name: electerm-android-dev-apks
path: build/android/android/app/build/outputs/apk/named/*.apk
if-no-files-found: error
retention-days: 30
- name: Configure AWS credentials for R2
run: |
mkdir -p ~/.aws
cat > ~/.aws/credentials << 'EOF'
[default]
aws_access_key_id = ${{ secrets.CF_R2_ACCESS_KEY_ID }}
aws_secret_access_key = ${{ secrets.CF_R2_SECRET_ACCESS_KEY }}
EOF
cat > ~/.aws/config << 'EOF'
[default]
region = auto
endpoint_url = ${{ secrets.CF_R2_ENDPOINT }}
EOF
- name: Upload dev APKs to R2 (root folder)
env:
CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }}
run: |
for file in build/android/android/app/build/outputs/apk/named/*.apk; do
[ -f "$file" ] || continue
fname=$(basename "$file")
aws s3 cp "$file" "s3://${CF_R2_BUCKET}/${fname}" \
--acl public-read \
--cache-control "max-age=31536000"
echo "Uploaded: ${fname}"
done
- name: Verify uploads
env:
CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }}
run: |
aws s3 ls "s3://${CF_R2_BUCKET}/" --recursive