#!/bin/bash # # android-build.sh allows building a MIQT Go application for Android. # For details, see the top-level README.md file. set -Eeuo pipefail # QT_PATH is pre-set in the Qt 5 docker container environment. Includes trailing slash # QT_ANDROID is pre-set in the Qt 6 docker container environment QT_PATH=${QT_PATH:-/usr/local/Qt-5.15.13/} QT_ANDROID=${QT_ANDROID:-$QT_PATH} export LC_ALL=C.UTF-8 # get_app_name returns the android app's name. This affects the default name # in deployment-settings.json and the generated lib.so names. # You can still customise the package name and the package ID in the xml # files after generation. get_app_name() { basename "$(pwd)" } get_stub_soname() { # libRealAppName_arm64-v8a.so echo "lib$(get_app_name)_arm64-v8a.so" } get_go_soname() { echo "libMiqtGolangApp_arm64-v8a.so" } ndk_version() { ls /opt/android-sdk/ndk/ | tail -n1 } target_sdk_version() { ls /opt/android-sdk/platforms | tail -n1 | sed -re 's/android-//' } build_tools_version() { ls /opt/android-sdk/build-tools | tail -n1 } # extra_libs returns a comma-separated list of extra libraries to include in # the apk package extra_libs() { if [[ -d /opt/android_openssl ]] ; then # Our miqt Qt5 container includes these extra .so libraries # However, the aqtinstall-based Qt 6 container does not use them echo "/opt/android_openssl/ssl_1.1/arm64-v8a/libssl_1_1.so,/opt/android_openssl/ssl_1.1/arm64-v8a/libcrypto_1_1.so" fi } # generate_template_contents produces a deployment settings JSON file that is # understood by the androiddeployqt program. # Available fields are documented in the template file at: # @ref /usr/local/Qt-5.15.13/mkspecs/features/android/android_deployment_settings.prf generate_template_contents() { cat < $STUBNAME < #include #include typedef void goMainFunc_t(); int main(int argc, char** argv) { __android_log_print(ANDROID_LOG_VERBOSE, "miqt_stub", "Starting up"); void* handle = dlopen("$(get_go_soname)", RTLD_LAZY); if (handle == NULL) { __android_log_print(ANDROID_LOG_VERBOSE, "miqt_stub", "miqt_stub: null handle opening so: %s", dlerror()); exit(1); } void* goMain = dlsym(handle, "AndroidMain"); if (goMain == NULL) { __android_log_print(ANDROID_LOG_VERBOSE, "miqt_stub", "miqt_stub: null handle looking for function: %s", dlerror()); exit(1); } __android_log_print(ANDROID_LOG_VERBOSE, "miqt_stub", "miqt_stub: Found target, calling"); // Cast to function pointer and call goMainFunc_t* f = (goMainFunc_t*)goMain; f(); __android_log_print(ANDROID_LOG_VERBOSE, "miqt_stub", "miqt_stub: Target function returned"); return 0; } EOF # Compile # Link with Qt libraries so that androiddeployqt detects us as being the # main shared library $CXX -shared \ -ldl \ -llog \ -L${QT_ANDROID}/plugins/platforms -lplugins_platforms_qtforandroid_arm64-v8a \ $(pkg-config --libs $(pkg_config_dependencies)) \ ${STUBNAME} \ "-Wl,-soname,$(basename "$(get_stub_soname)")" \ -o "android-build/libs/arm64-v8a/$(get_stub_soname)" rm "${STUBNAME}" } # require_is_main_package verifies that this is the main Go package. require_is_main_package() { if ! grep -Fq 'package main' *.go ; then echo "This doesn't seem to be the main package" >&2 exit 1 fi } # patch_app_main sets up the startup go files so that the go program can be # built either as c-shared for Android or as a normal program for desktop OSes. patch_app_main() { # Replace func main() with app_main() for srcfile in *.go ; do sed -i -re 's/^func main\(\) \{/func app_main() {/' "$srcfile" done # Add shim startup files cat < startup_android.go //go:build android // +build android package main import "C" // Required for export support //export AndroidMain func AndroidMain() { app_main() } func main() { // Must be empty } EOF cat < startup_other.go //go:build !android // +build !android package main func main() { app_main() } EOF gofmt -w startup_android.go || true gofmt -w startup_other.go || true # Done } # unpatch_app_main undoes the transformation from patch_app_main. unpatch_app_main() { # Replace func main() with app_main() for srcfile in *.go ; do sed -i -re 's/^func app_main\(\) \{/func main() {/' "$srcfile" done # Remove extra startup files rm startup_android.go || true rm startup_other.go || true } # build_app_so compiles the Go app as a c-shared .so. build_app_so() { go build \ -buildmode c-shared \ -ldflags "-s -w -extldflags -Wl,-soname,$(get_go_soname)" \ -o "android-build/libs/arm64-v8a/$(get_go_soname)" } sdkmanager() { echo /opt/android-sdk/cmdline-tools/*/bin/sdkmanager } # build_apk calls androiddeployqt to package the android-build directory into # the final apk. build_apk() { # Qt 6 androiddeployqt: Understands the QT_ANDROID_KEYSTORE_STORE_PASS in env # Qt 5 androiddeployqt: Doesn't - any use of `--sign keystore alias` here # requires stdin prompt but doesn't pass androiddeployqt's stdin through # to jarsigner subprocess # Either way, don't sign the app here, rely on separate jarsigner command # Work around an issue with Qt 6 sporadically failing to detect that # we have a valid Qt platform plugin # TODO why does this happen? Is it related to file sort ordering? # It really does fix itself after multiple attempts (usually less than 5) # - When it fails: Error: qmlimportscanner not found at /qmlimportscanner # - When it works: Error: qmlimportscanner not found at libexec/qmlimportscanner while ! androiddeployqt \ --input ./deployment-settings.json \ --output ./android-build/ \ --release \ 2> >(tee /tmp/androiddeployqt.stderr.log >&2) ; do if grep -Fq 'Make sure the app links to Qt Gui library' /tmp/androiddeployqt.stderr.log ; then echo "Detected temporary problem with Qt plugin detection. Retrying..." sleep 1 else # real error exit 1 fi done local OUTAPK=$(get_app_name).apk rm "$OUTAPK" || true # Zipalign echo "Zipalign..." /opt/android-sdk/build-tools/*/zipalign \ -p 4 \ ./android-build/build/outputs/apk/release/android-build-release-unsigned.apk \ "$OUTAPK" # Sign echo "Signing..." #jarsigner \ # -verbose \ # -sigalg SHA256withRSA -digestalg SHA256 \ # -keystore ./android.keystore \ # "$OUTAPK" \ # "${QT_ANDROID_KEYSTORE_ALIAS}" \ # -storepass:env QT_ANDROID_KEYSTORE_STORE_PASS \ # -keypass:env QT_ANDROID_KEYSTORE_KEY_PASS /opt/android-sdk/build-tools/*/apksigner \ sign \ --ks ./android.keystore \ --ks-key-alias "${QT_ANDROID_KEYSTORE_ALIAS}" \ --ks-pass env:QT_ANDROID_KEYSTORE_STORE_PASS \ --key-pass env:QT_ANDROID_KEYSTORE_KEY_PASS \ "$OUTAPK" } # detect_env_qt_version detects the system's current Qt version. detect_env_qt_version() { if qmake --version | fgrep -q 'Qt version 5' ; then echo "qt5" return 0 elif qmake --version | fgrep -q 'Qt version 6' ; then echo "qt6" return 0 else echo "Missing Qt tools in PATH" >&2 exit 1 fi } # detect_miqt_qt_version echoes either "qt5", "qt6", or exits bash. detect_miqt_qt_version() { local IS_QT5=false if grep -qF '"github.com/mappu/miqt/qt"' *.go ; then IS_QT5=true fi local IS_QT6=false if grep -qF '"github.com/mappu/miqt/qt6"' *.go ; then IS_QT6=true fi if [[ $IS_QT5 == true && $IS_QT6 == true ]] ; then echo "Found qt5 and qt6 imports, confused about what to do next" >&2 exit 1 elif [[ $IS_QT5 == true ]] ; then echo "qt5" return 0 elif [[ $IS_QT6 == true ]] ; then echo "qt6" return 0 else echo "Found neither qt5 nor qt6 imports. Is this a MIQT Qt app?" >&2 exit 1 fi } generate_default_keystore() { local GENPASS=storepass_$(cat /dev/urandom | head -c64 | md5sum | cut -d' ' -f1) keytool \ -genkeypair \ -dname "cn=Miqt, ou=Miqt, o=Miqt, c=US" \ -keyalg RSA \ -alias miqt \ -keypass "${GENPASS}" \ -keystore ./android.keystore \ -storepass "${GENPASS}" \ -validity 20000 echo "QT_ANDROID_KEYSTORE_PATH=./android.keystore" > android.keystore.env echo "QT_ANDROID_KEYSTORE_ALIAS=miqt" >> android.keystore.env echo "QT_ANDROID_KEYSTORE_STORE_PASS=${GENPASS}" >> android.keystore.env echo "QT_ANDROID_KEYSTORE_KEY_PASS=${GENPASS}" >> android.keystore.env } # main is the entrypoint for android-build.sh. main() { if [[ $(detect_env_qt_version) != $(detect_miqt_qt_version) ]] ; then echo "The system is $(detect_env_qt_version) but the app uses $(detect_miqt_qt_version). Is this the right container?" >&2 exit 1 fi require_is_main_package # Rebuild deployment-settings.json if [[ ! -f deployment-settings.json ]] ; then echo "Generating deployment-settings.json..." generate_template_contents > deployment-settings.json fi mkdir -p android-build/libs/arm64-v8a if [[ ! -f android-build/libs/arm64-v8a/$(get_stub_soname) ]] ; then echo "Generating stub so..." android_stub_gen fi # Rebuild miqt_golang_app.so echo "Compiling Go app..." patch_app_main build_app_so unpatch_app_main # Keypair if [[ ! -f android.keystore || ! -f android.keystore.env ]] ; then echo "Signing keystore not found, generating a default one..." generate_default_keystore fi # Load keypair credentials into exported env vars set -o allexport source android.keystore.env set +o allexport # Generate .apk echo "Packaging APK..." build_apk echo "Complete" } main "$@"