Repository navigation
Expand file tree
/
Copy pathbundle-android.sh
More file actions
executable file
·417 lines (388 loc) · 17.3 KB
/
Copy pathbundle-android.sh
File metadata and controls
executable file
·417 lines (388 loc) · 17.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
#!/usr/bin/env bash
# Bundle a built Android native library into a signed APK.
#
# Usage:
# bundle-android.sh --lib <path/to/liblba2cc.so> \
# --version <version-string> \
# --arch <arm64-v8a> \
# --build-dir <cmake-build-dir> \
# --sdk-root <android-sdk> \
# --sdl3-java-src <path-to-sdl3-java-sources> \
# --output-dir <where-to-drop-the-apk> \
# [--keystore <file> --keystore-pass <pass> \
# --key-alias <alias> [--key-pass <pass>]] \
# [--expect-cert <sha256>] \
# [--split-symbols]
#
# Produces: <output-dir>/lba2cc-<version>-android-<arch>.apk
#
# --split-symbols moves the debug info of every library the APK ships into
# <output-dir>/lba2cc-<version>-android-<arch>-symbols.tar.xz with
# split-symbols.sh, before the libraries are stripped. The NDK compiles with -g
# by default, so every Release build has it.
#
# Signing. Android identifies an app by package name AND signing certificate,
# so two APKs signed with different keys are two different apps: installing one
# over the other is refused with INSTALL_FAILED_UPDATE_INCOMPATIBLE, which the
# package installer shows as "App not installed as package conflicts with an
# existing package". The only way past it is to uninstall, and that erases
# everything in the app's private storage.
#
# Pass a keystore (flags above, or LBA2_ANDROID_KEYSTORE / _KEYSTORE_PASS /
# _KEY_ALIAS / _KEY_PASS in the environment) and every release signed with it
# updates in place. With none, this falls back to the local debug keystore,
# generating one if the machine has none -- which is fine for a build you
# install yourself and wrong for anything a player is handed, because a fresh
# CI runner generates a fresh key on every single run.
#
# --expect-cert is the SHA-256 of the certificate the finished APK must carry
# (LBA2_ANDROID_EXPECT_CERT in the environment). Signing succeeds with whatever
# key it is given, so without this a wrong keystore, a wrong alias or a truncated
# secret all produce a perfectly valid APK that no existing install will accept.
# That failure is only visible to the player, on the day they try to update.
#
# Requires the Android SDK (command-line tools, build-tools, platform
# android-34) to be installed at SDK_ROOT.
set -euo pipefail
LIB_PATH=""
VERSION=""
ARCH="arm64-v8a"
BUILD_DIR=""
SDK_ROOT=""
SDL3_JAVA_SRC=""
SDL3_LIB=""
CXX_SHARED_LIB=""
OUTPUT_DIR=""
SPLIT_SYMBOLS=0
KEYSTORE="${LBA2_ANDROID_KEYSTORE:-}"
KEYSTORE_PASS="${LBA2_ANDROID_KEYSTORE_PASS:-}"
KEY_ALIAS="${LBA2_ANDROID_KEY_ALIAS:-}"
KEY_PASS="${LBA2_ANDROID_KEY_PASS:-}"
EXPECT_CERT="${LBA2_ANDROID_EXPECT_CERT:-}"
while [[ $# -gt 0 ]]; do
case "$1" in
--lib) LIB_PATH="$2"; shift 2 ;;
--version) VERSION="$2"; shift 2 ;;
--arch) ARCH="$2"; shift 2 ;;
--build-dir) BUILD_DIR="$2"; shift 2 ;;
--sdk-root) SDK_ROOT="$2"; shift 2 ;;
--sdl3-java-src) SDL3_JAVA_SRC="$2"; shift 2 ;;
--sdl3-lib) SDL3_LIB="$2"; shift 2 ;;
--cxx-shared-lib) CXX_SHARED_LIB="$2"; shift 2 ;;
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
--keystore) KEYSTORE="$2"; shift 2 ;;
--keystore-pass) KEYSTORE_PASS="$2"; shift 2 ;;
--key-alias) KEY_ALIAS="$2"; shift 2 ;;
--key-pass) KEY_PASS="$2"; shift 2 ;;
--expect-cert) EXPECT_CERT="$2"; shift 2 ;;
--split-symbols) SPLIT_SYMBOLS=1; shift ;;
-h|--help)
sed -n '/^# Usage:/,/^set -e/p' "$0" | sed 's/^# \?//' | head -n -1
exit 0
;;
*) echo "Unknown argument: $1" >&2; exit 2 ;;
esac
done
for var in LIB_PATH VERSION BUILD_DIR SDK_ROOT OUTPUT_DIR; do
if [[ -z "${!var}" ]]; then
echo "bundle-android.sh: missing required arg: ${var,,}" >&2
exit 2
fi
done
if [[ -z "$SDL3_JAVA_SRC" ]]; then
echo "bundle-android.sh: missing required arg: sdl3-java-src" >&2
exit 2
fi
if [[ ! -f "$LIB_PATH" ]]; then
echo "bundle-android.sh: native lib not found at $LIB_PATH" >&2
exit 1
fi
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
PACKAGE="org.lbalab.lba2cc"
LIB_NAME="libmain.so"
ARTIFACT_NAME="lba2cc-${VERSION}-android-${ARCH}"
ARTIFACT_APK="${OUTPUT_DIR}/${ARTIFACT_NAME}.apk"
# versionCode is the integer Android orders builds by, and the manifest carried
# a hard-coded 1, so every release we have ever shipped claimed to be the same
# build. Derive it from the leading X.Y.Z of the version, ignoring any -dev or
# -rc suffix: 0.12.0 -> 1200, 0.13.0 -> 1300. Two digits each for minor and
# patch, which is room this project will not run out of before the major moves.
VERSION_CODE=$(
echo "$VERSION" | awk -F'[.-]' '{
printf "%d", ($1 * 10000) + ($2 * 100) + $3
}'
)
if [[ ! "$VERSION_CODE" =~ ^[0-9]+$ ]] || [[ "$VERSION_CODE" -le 0 ]]; then
echo "bundle-android: could not derive a versionCode from '$VERSION'" >&2
exit 1
fi
echo "[bundle-android] lib: $LIB_PATH"
echo "[bundle-android] version: $VERSION (versionCode $VERSION_CODE)"
echo "[bundle-android] arch: $ARCH"
echo "[bundle-android] artifact: $ARTIFACT_APK"
# Locate Android SDK tools
BUILD_TOOLS=$(ls -d "$SDK_ROOT/build-tools/"* 2>/dev/null | sort -V | tail -1)
if [[ -z "$BUILD_TOOLS" ]]; then
echo "bundle-android: no build-tools found under SDK_ROOT/build-tools/" >&2
exit 1
fi
AAPT="$BUILD_TOOLS/aapt"
AAPT2="$BUILD_TOOLS/aapt2"
ZIPALIGN="$BUILD_TOOLS/zipalign"
APKSIGNER="$BUILD_TOOLS/apksigner"
for tool in "$AAPT" "$ZIPALIGN" "$APKSIGNER"; do
if [[ ! -x "$tool" ]]; then
echo "bundle-android: required tool not found: $tool" >&2
exit 1
fi
done
# Fresh staging directory
STAGING="${OUTPUT_DIR}/apk-staging"
rm -rf "$STAGING"
mkdir -p "$STAGING/lib/$ARCH"
mkdir -p "$STAGING/res/values"
# 1. Place native libraries
cp "$LIB_PATH" "$STAGING/lib/$ARCH/$LIB_NAME"
# Bundle all SDL3 shared libs from the install prefix
if [[ -n "$SDL3_LIB" ]]; then
SDL3_LIBDIR="$(dirname "$SDL3_LIB")"
if [[ -d "$SDL3_LIBDIR" ]]; then
for lib in "$SDL3_LIBDIR"/*.so; do
[[ -f "$lib" ]] && cp "$lib" "$STAGING/lib/$ARCH/"
done
fi
fi
# Bundle C++ runtime — auto-discover from the NDK via CMakeCache.txt,
# falling back to the explicit --cxx-shared-lib flag if provided.
if [[ -n "$CXX_SHARED_LIB" && -f "$CXX_SHARED_LIB" ]]; then
cp "$CXX_SHARED_LIB" "$STAGING/lib/$ARCH/"
elif [[ -f "$BUILD_DIR/CMakeCache.txt" ]]; then
# A cache configured through the NDK toolchain file carries no
# CMAKE_ANDROID_NDK, and a grep miss under `set -e` would end the run right
# here with no output at all. Let it through: emptiness is handled below,
# and the toolchain paths still name the NDK when that variable does not.
NDK_ROOT=$(grep -m1 '^CMAKE_ANDROID_NDK:' "$BUILD_DIR/CMakeCache.txt" 2>/dev/null | cut -d= -f2- || true)
ABI=$(grep -m1 '^ANDROID_ABI:' "$BUILD_DIR/CMakeCache.txt" 2>/dev/null | cut -d= -f2- || true)
if [[ -z "$NDK_ROOT" ]]; then
NDK_AR=$(grep -m1 '^CMAKE_AR:' "$BUILD_DIR/CMakeCache.txt" 2>/dev/null | cut -d= -f2- || true)
[[ "$NDK_AR" == */toolchains/* ]] && NDK_ROOT="${NDK_AR%%/toolchains/*}"
fi
ABI="${ABI:-$ARCH}"
if [[ -n "$NDK_ROOT" && -n "$ABI" ]]; then
case "$ABI" in
arm64-v8a) NDK_TARGET="aarch64-linux-android" ;;
armeabi-v7a) NDK_TARGET="arm-linux-androideabi" ;;
x86_64) NDK_TARGET="x86_64-linux-android" ;;
x86) NDK_TARGET="i686-linux-android" ;;
esac
case "$(uname -s)" in
Linux) HOST_TAG="linux-x86_64" ;;
Darwin) HOST_TAG="darwin-x86_64" ;;
*) HOST_TAG="" ;;
esac
if [[ -n "$NDK_TARGET" && -n "$HOST_TAG" ]]; then
CXX_SHARED_LIB="${NDK_ROOT}/toolchains/llvm/prebuilt/${HOST_TAG}/sysroot/usr/lib/${NDK_TARGET}/libc++_shared.so"
if [[ -f "$CXX_SHARED_LIB" ]]; then
echo "[bundle-android] bundling: $CXX_SHARED_LIB"
cp "$CXX_SHARED_LIB" "$STAGING/lib/$ARCH/"
fi
fi
fi
fi
# An APK missing a library the native code links against installs cleanly and
# then dies at load time, which is a worse failure than not building at all.
if grep -aq 'libc++_shared\.so' "$LIB_PATH" 2>/dev/null \
&& [[ ! -f "$STAGING/lib/$ARCH/libc++_shared.so" ]]; then
echo "bundle-android: $LIB_PATH links against libc++_shared.so, which could" >&2
echo " not be located from the build directory. Pass it explicitly with" >&2
echo " --cxx-shared-lib <path to libc++_shared.so>." >&2
exit 1
fi
# 1a. Drop the debug info the NDK's toolchain compiles in by default. It is most of
# every library's size, and it is not what a crash report needs: the symbol table
# stays, which names the functions in a tombstone and in the engine's crash block.
# Asked to, keep it in the symbol archive first.
if [[ "$SPLIT_SYMBOLS" == 1 ]]; then
OBJCOPY_TOOL=$(grep -m1 '^CMAKE_OBJCOPY:' "$BUILD_DIR/CMakeCache.txt" 2>/dev/null | cut -d= -f2- || true)
SPLIT_ARGS=()
for lib in "$STAGING/lib/$ARCH"/*.so; do
SPLIT_ARGS+=(--binary "$lib")
done
bash "$REPO_ROOT/scripts/packaging/split-symbols.sh" \
${OBJCOPY_TOOL:+--objcopy "$OBJCOPY_TOOL"} \
"${SPLIT_ARGS[@]}" \
--output "$OUTPUT_DIR/$ARTIFACT_NAME-symbols.tar.xz"
fi
STRIP_TOOL=$(grep -m1 '^CMAKE_STRIP:' "$BUILD_DIR/CMakeCache.txt" 2>/dev/null | cut -d= -f2- || true)
if [[ -z "$STRIP_TOOL" || ! -x "$STRIP_TOOL" ]]; then
echo "bundle-android: no strip tool in $BUILD_DIR/CMakeCache.txt (CMAKE_STRIP)," >&2
echo " so the libraries would ship with their debug info." >&2
exit 1
fi
for lib in "$STAGING/lib/$ARCH"/*.so; do
before=$(wc -c < "$lib" | tr -d ' ')
"$STRIP_TOOL" --strip-debug "$lib"
echo "[bundle-android] stripped debug info: $(basename "$lib") $before -> $(wc -c < "$lib" | tr -d ' ') bytes"
done
# 1b. App icon — resolvable as @mipmap/ic_launcher in the manifest
ICON_SRC="$REPO_ROOT/packaging/lba2cc.png"
if [[ -f "$ICON_SRC" ]]; then
mkdir -p "$STAGING/res/mipmap-hdpi"
cp "$ICON_SRC" "$STAGING/res/mipmap-hdpi/ic_launcher.png"
fi
# 2. AndroidManifest.xml — resolve @string references from the manifest
PRODUCT_NAME="${LBA2_PRODUCT_NAME:-LBA2 Classic Community}"
cat > "$STAGING/res/values/strings.xml" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">${PRODUCT_NAME}</string>
<string name="version_name">${VERSION}</string>
</resources>
EOF
cp "$REPO_ROOT/packaging/android/AndroidManifest.xml" "$STAGING/AndroidManifest.xml"
# The manifest keeps a literal versionCode so it stays a valid manifest to read
# and to build by hand; the real value is substituted here. Checked rather than
# assumed: a manifest edit that renamed or reformatted the attribute would
# otherwise ship every build as version 1 again, silently, which is the bug this
# replaces.
# [0-9][0-9]* rather than [0-9]\+: the latter is a GNU extension to basic
# regular expressions, so on a BSD userland it matches a literal + and this
# substitution quietly leaves the manifest saying 1.
if ! grep -q 'android:versionCode="[0-9][0-9]*"' "$STAGING/AndroidManifest.xml"; then
echo "bundle-android: no android:versionCode to substitute in the manifest" >&2
exit 1
fi
sed -i.bak "s/android:versionCode=\"[0-9][0-9]*\"/android:versionCode=\"${VERSION_CODE}\"/" \
"$STAGING/AndroidManifest.xml"
rm -f "$STAGING/AndroidManifest.xml.bak"
# The grep above says an attribute was there to replace, not that the replacing
# happened. Those are different failures and only this one is silent.
if ! grep -q "android:versionCode=\"${VERSION_CODE}\"" "$STAGING/AndroidManifest.xml"; then
echo "bundle-android: the versionCode substitution did not take" >&2
exit 1
fi
# 3. Compile SDL Java sources (plus helpers from packaging/android/java/) into classes.dex
echo "[bundle-android] compiling Java to DEX..."
SDL_JAVA_DIR="${SDL3_JAVA_SRC}/android-project/app/src/main/java"
REPO_JAVA_DIR="$REPO_ROOT/packaging/android/java"
D8="$BUILD_TOOLS/d8"
# Gather SDL sources
SDL_JAVA_FILES=""
if [[ -d "$SDL_JAVA_DIR" ]]; then
SDL_JAVA_FILES=$(find "$SDL_JAVA_DIR" -name '*.java' 2>/dev/null | tr '\n' ' ')
fi
# Gather repo-local helpers
REPO_JAVA_FILES=""
if [[ -d "$REPO_JAVA_DIR" ]]; then
REPO_JAVA_FILES=$(find "$REPO_JAVA_DIR" -name '*.java' 2>/dev/null | tr '\n' ' ')
fi
# Merge (strip leading/trailing whitespace for -n check)
ALL_JAVA_FILES="${SDL_JAVA_FILES} ${REPO_JAVA_FILES}"
ALL_JAVA_FILES="${ALL_JAVA_FILES#"${ALL_JAVA_FILES%%[![:space:]]*}"}"
ALL_JAVA_FILES="${ALL_JAVA_FILES%"${ALL_JAVA_FILES##*[![:space:]]}"}"
if [[ -n "$ALL_JAVA_FILES" && -x "$D8" ]]; then
mkdir -p "$STAGING/obj"
javac -d "$STAGING/obj" \
-classpath "$SDK_ROOT/platforms/android-34/android.jar" \
$ALL_JAVA_FILES 2>&1
mapfile -t CLASS_FILES < <(find "$STAGING/obj" -name '*.class')
"$D8" --lib "$SDK_ROOT/platforms/android-34/android.jar" \
--output "$STAGING" "${CLASS_FILES[@]}" 2>&1
rm -rf "$STAGING/obj"
fi
# 4. Build APK with aapt
echo "[bundle-android] packaging APK..."
"$AAPT" package -f -M "$STAGING/AndroidManifest.xml" \
-S "$STAGING/res" \
-I "$SDK_ROOT/platforms/android-34/android.jar" \
-F "$STAGING/unsigned.apk" 2>&1
# 5. Add classes.dex and native lib to the APK (store .so uncompressed)
echo "[bundle-android] adding native lib and DEX..."
cd "$STAGING"
if [[ -f classes.dex ]]; then
"$AAPT" add "unsigned.apk" "classes.dex" 2>&1
fi
for lib in "lib/$ARCH/"*.so; do
[[ -f "$lib" ]] && "$AAPT" add -0 '' "unsigned.apk" "$lib" 2>&1
done
cd "$REPO_ROOT"
# 6. Zipalign (-P 16: align uncompressed .so to 16 KB pages for 16 KB-page devices)
echo "[bundle-android] aligning..."
"$ZIPALIGN" -f -P 16 4 "$STAGING/unsigned.apk" "$STAGING/aligned.apk"
# 7. Sign — see the header for why the identity of the key matters
if [[ -n "$KEYSTORE" ]]; then
if [[ ! -f "$KEYSTORE" ]]; then
echo "bundle-android: keystore not found at $KEYSTORE" >&2
exit 1
fi
if [[ -z "$KEYSTORE_PASS" || -z "$KEY_ALIAS" ]]; then
echo "bundle-android: --keystore needs --keystore-pass and --key-alias" >&2
exit 1
fi
# A key with no separate password uses the store's, which is how a keystore
# holding one key is usually made.
KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
echo "[bundle-android] signing with the release key ($KEY_ALIAS)"
else
KEYSTORE="${HOME}/.android/debug.keystore"
KEYSTORE_PASS="android"
KEY_ALIAS="androiddebugkey"
KEY_PASS="android"
if [[ ! -f "$KEYSTORE" ]]; then
echo "[bundle-android] generating debug keystore..."
mkdir -p "${HOME}/.android"
keytool -genkey -v -keystore "$KEYSTORE" \
-alias "$KEY_ALIAS" -storepass "$KEYSTORE_PASS" -keypass "$KEY_PASS" \
-keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Android Debug,O=Android,C=US" 2>&1
fi
echo "[bundle-android] signing with the local debug key."
echo "[bundle-android] NOTE: this APK can only update another one signed by"
echo "[bundle-android] the same machine's debug key. Do not publish it."
fi
# pass: puts the password in the argument list, and on Linux that is world
# readable through /proc/<pid>/cmdline for as long as the process runs. env: is
# the same string somewhere only this user can read it.
export LBA2_APKSIGNER_STORE_PASS="$KEYSTORE_PASS"
export LBA2_APKSIGNER_KEY_PASS="$KEY_PASS"
"$APKSIGNER" sign --ks "$KEYSTORE" \
--ks-pass env:LBA2_APKSIGNER_STORE_PASS \
--ks-key-alias "$KEY_ALIAS" \
--key-pass env:LBA2_APKSIGNER_KEY_PASS \
--out "$ARTIFACT_APK" "$STAGING/aligned.apk"
unset LBA2_APKSIGNER_STORE_PASS LBA2_APKSIGNER_KEY_PASS
# 8. Verify, and check which key it was signed with.
#
# The digest is the app's identity as far as Android is concerned, so a release
# log that carries it is how anyone can check afterwards that two builds really
# can update each other -- which is exactly the question nobody could answer
# about the releases signed by throwaway keys.
echo "[bundle-android] verifying..."
"$APKSIGNER" verify "$ARTIFACT_APK" 2>&1
CERT_OUT=$("$APKSIGNER" verify --print-certs "$ARTIFACT_APK" 2>&1)
CERT_SHA=$(printf '%s\n' "$CERT_OUT" | sed -n 's/.*[Cc]ertificate SHA-256 digest: *//p' |
sed -n '1p' | tr 'A-Z' 'a-z')
if [[ -z "$CERT_SHA" ]]; then
echo "bundle-android: signed, but the certificate could not be read back" >&2
exit 1
fi
echo "[bundle-android] certificate SHA-256: $CERT_SHA"
# Signing is happy with any key it is handed, so the only thing separating the
# release key from a wrong one is this comparison.
if [[ -n "$EXPECT_CERT" ]]; then
WANT=$(printf '%s' "$EXPECT_CERT" | tr -d ': ' | tr 'A-Z' 'a-z')
if [[ "$CERT_SHA" != "$WANT" ]]; then
echo "bundle-android: this APK carries a certificate nobody expected." >&2
echo " expected $WANT" >&2
echo " got $CERT_SHA" >&2
echo " Published, it would tell every existing player that the package" >&2
echo " conflicts with the one they have, and the only way past that is an" >&2
echo " uninstall, which erases their saves. Check the keystore and alias." >&2
exit 1
fi
echo "[bundle-android] certificate is the expected release key."
fi
# 9. Cleanup
rm -rf "$STAGING"
APK_SIZE=$(du -h "$ARTIFACT_APK" | cut -f1)
echo "[bundle-android] done: $ARTIFACT_APK ($APK_SIZE)"