diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml new file mode 100644 index 000000000..0453c0eca --- /dev/null +++ b/.github/workflows/fork-release.yml @@ -0,0 +1,217 @@ +name: Build APK and publish release + +# Builds a signed *release* APK on GitHub's runners and publishes it as a +# GitHub Release asset. The existing release.yml only builds debug APKs and +# needs a maintainer keystore, so forks had no way to ship a non-debuggable +# build from CI. +# +# Signing: set the KEYSTORE_BASE64 / KEYSTORE_PASSWORD / KEY_ALIAS / +# KEY_PASSWORD repository secrets to sign with a stable key. Without them a +# throwaway key is generated so the build still succeeds; the release notes +# record which key was used so users know whether they can install it over an +# existing copy. + +on: + workflow_dispatch: + inputs: + version: + description: 'Release tag to publish, e.g. fork-v1.0.0 (default: fork-)' + required: false + default: '' + push: + tags: + - 'fork-v*' + +permissions: + contents: write + +concurrency: + group: fork-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Clone repository + uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 1 + lfs: false + persist-credentials: false + + - name: Pull LFS files + run: git lfs pull + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Install Rust Android target + run: rustup target add aarch64-linux-android + + # The ubuntu-latest image already ships an Android SDK; android-actions/ + # setup-android@v3 fails on it because it insists on installing the + # long-removed `tools` package. Use the preinstalled SDK like the other + # workflows in this repository do. + - name: Install NDK and CMake + run: | + set -euo pipefail + sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" + if [ -z "$sdk" ] || [ ! -d "$sdk" ]; then + echo "::error::No Android SDK on this runner (ANDROID_SDK_ROOT/ANDROID_HOME unset)" + exit 1 + fi + echo "ANDROID_SDK_ROOT=$sdk" >> "$GITHUB_ENV" + echo "ANDROID_HOME=$sdk" >> "$GITHUB_ENV" + sdkmanager_bin="$(find "$sdk/cmdline-tools" -maxdepth 3 -name sdkmanager -type f 2>/dev/null | sort -V | tail -1)" + if [ -z "$sdkmanager_bin" ]; then + echo "::warning::sdkmanager not found under $sdk; letting Gradle install NDK and CMake" + exit 0 + fi + echo "Using $sdkmanager_bin" + yes | "$sdkmanager_bin" --licenses >/dev/null 2>&1 || true + "$sdkmanager_bin" "ndk;27.3.13750724" "cmake;3.22.1" "platforms;android-35" "build-tools;35.0.0" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Configure signing + id: signing + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + run: | + set -euo pipefail + if [ -n "${KEYSTORE_BASE64:-}" ]; then + printf '%s' "$KEYSTORE_BASE64" | base64 -d > release.jks + store_password="${KEYSTORE_PASSWORD:?KEYSTORE_PASSWORD is required with KEYSTORE_BASE64}" + key_password="${KEY_PASSWORD:-$store_password}" + key_alias="${KEY_ALIAS:?KEY_ALIAS is required with KEYSTORE_BASE64}" + printf 'CI signed with the repository keystore secret.\n' > signing-note.txt + echo "stable=yes" >> "$GITHUB_OUTPUT" + else + echo "::warning::No KEYSTORE_BASE64 secret; generating a throwaway signing key. Users cannot install this APK over an existing copy signed with a different key." + keytool -genkeypair -keystore release.jks -alias winnative \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -storepass ephemeral123 -keypass ephemeral123 \ + -dname "CN=WinNative CI (ephemeral), OU=CI, O=CI, L=CI, C=CI" >/dev/null 2>&1 + store_password=ephemeral123 + key_password=ephemeral123 + key_alias=winnative + printf 'Signed with a throwaway CI key; uninstall any existing copy first.\n' > signing-note.txt + echo "stable=no" >> "$GITHUB_OUTPUT" + fi + cat > signing.properties </dev/null | sort -V | tail -1)" + echo "build-tools: ${build_tools:-}" + if [ -z "${build_tools:-}" ] || [ ! -x "$build_tools/aapt2" ]; then + echo "::error::aapt2 not found under $ANDROID_SDK_ROOT/build-tools" + exit 1 + fi + if ! "$build_tools/aapt2" dump badging "$apk" > badging.txt; then + echo "::error::aapt2 dump badging failed" + exit 1 + fi + if grep -q "application-debuggable" badging.txt; then + echo "::error::release APK is debuggable; Android would show the 16 KB compatibility warning" + exit 1 + fi + echo "APK is not debuggable" + + # libwnsteam.so is produced by cargo, which drives the NDK linker + # itself; a regression there would silently fall back to 4 KB pages. + rm -rf apk-lib + mkdir -p apk-lib + if ! unzip -o -q "$apk" "lib/arm64-v8a/libwnsteam.so" -d apk-lib; then + echo "::error::could not extract libwnsteam.so from the APK" + exit 1 + fi + ls -l apk-lib/lib/arm64-v8a/ + readelf_bin="$(ls -d "$ANDROID_SDK_ROOT"/ndk/27.3.13750724/toolchains/llvm/prebuilt/*/bin/llvm-readelf 2>/dev/null | head -1)" + echo "llvm-readelf: ${readelf_bin:-}" + if [ -z "${readelf_bin:-}" ] || [ ! -x "$readelf_bin" ]; then + echo "::error::llvm-readelf not found in NDK 27.3.13750724" + exit 1 + fi + # Write to a file instead of piping into awk: an early awk exit sends + # SIGPIPE to readelf and aborts the step under `bash -e`. + "$readelf_bin" -lW apk-lib/lib/arm64-v8a/libwnsteam.so > readelf-headers.txt + align="$(awk '/LOAD/ { print $NF; exit }' readelf-headers.txt)" + echo "libwnsteam.so LOAD align: ${align:-}" + if [ "$align" != "0x4000" ]; then + echo "::error::libwnsteam.so is not 16 KB aligned (align=${align:-})" + exit 1 + fi + + - name: Work out release tag + id: tag + run: | + set -euo pipefail + tag="${{ inputs.version }}" + if [ -z "$tag" ]; then + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + tag="${GITHUB_REF_NAME}" + else + tag="fork-${GITHUB_RUN_NUMBER}" + fi + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.tag }} + name: WinNative ${{ steps.tag.outputs.tag }} + draft: false + prerelease: true + body: | + Release APK built from `${{ github.sha }}` by the + `Build APK and publish release` workflow on GitHub's runners. + + - flavour: `standard` (application id `com.winnative.cmod`) + - build type: **release** (not debuggable, so Android does not show the + 16 KB page-size compatibility warning) + - Steam download fixes: CDN auth token (`GetCDNAuthToken#1`), logon + `cell_id` reuse, Asia-Pacific CDN fallback, adaptive host scheduling + with a pre-flight reachability probe + + $(cat signing-note.txt) + + `libcrypto.so`, `libssl.so` and `libcurl.so` still come from the + `com.android.ndk.thirdparty` prefab artefacts, which are built for + 4 KB pages; devices booted with a 16 KB page-size kernel are + therefore not supported yet. + files: app/build/outputs/apk/standard/release/app-standard-release.apk + fail_on_unmatched_files: true diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 8fab7dfc3..ea76ee0a1 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -106,3 +106,43 @@ -dontwarn androidx.window.sidecar.SidecarInterface -dontwarn androidx.window.sidecar.SidecarProvider -dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo + +# snakeyaml's optional JavaBeans introspection path is unreachable on Android +# (java.beans.* is not part of the platform), but R8 still resolves its +# references and fails the release build without these suppressions. +-dontwarn java.beans.** +-dontwarn org.yaml.snakeyaml.introspector.** + +# The app talks to its own native code in both directions: ~20 Java/Kotlin +# classes declare native/external methods that resolve to exported +# Java___ symbols, and libwinlator/libwaylandcomp/ +# libwnsteam look callbacks up by name with GetMethodID (for example +# OnExtractFileListener.onExtractFileProgress, WaylandCompositor.onGameFrame, +# GPUImage.setStride, XConnectorEpoll.ConnectionHandler.handleNewConnection). +# Renaming any of those makes the lookup throw NoSuchMethodError at runtime, +# which is exactly what a release build hit while extracting imagefs. +# Keep names so the JNI surface stays addressable; shrinking and optimisation +# still run. +-dontobfuscate + +# R8 cannot see calls that cross the JNI boundary, so it treats every Java method +# that only native code calls as dead and removes it. That broke imagefs +# extraction in the first release build: libwinlator looked up +# OnExtractFileListener.onExtractFileProgress with GetMethodID and got +# NoSuchMethodError because the method had been shrunk away. The same applies to +# the other direction - WaylandCompositor's static frame callbacks, the Xconnector +# connection handlers, GPUImage.setStride, and the Kotlin listeners that the Rust +# Steam client calls back (onProgress, onComplete, onAuthResult, ...). +# Keep the app's own classes intact and let R8 shrink only third-party code. +-keep class com.winlator.** { *; } +-keep class com.winnative.** { *; } +-keep class org.love2d.** { *; } + + +# androidx.security.crypto builds its keys through Tink, which registers +# algorithm implementations reflectively. Without these keeps, a release build +# loses them and EncryptedSharedPreferences.create() throws at runtime, taking +# every Activity down through PrefManager's initialization. +-keep class androidx.security.crypto.** { *; } +-keep class com.google.crypto.tink.** { *; } +-dontwarn com.google.crypto.tink.** diff --git a/app/src/main/cpp/proot/CMakeLists.txt b/app/src/main/cpp/proot/CMakeLists.txt index e6f7ca3e9..c9a0b67a1 100644 --- a/app/src/main/cpp/proot/CMakeLists.txt +++ b/app/src/main/cpp/proot/CMakeLists.txt @@ -20,7 +20,11 @@ add_executable(libproot-loader.so target_include_directories(libproot-loader.so PRIVATE src) target_compile_definitions(libproot-loader.so PRIVATE ${PROOT_DEFINES}) target_compile_options(libproot-loader.so PRIVATE ${PROOT_CFLAGS} -fPIC -ffreestanding -fno-stack-protector) +# Release builds add -Wl,--gc-sections; without a GC root the linker discards the +# unreferenced pokedata_workaround stub that loader-info.cmake measures, which made +# assembleRelease fail. -u keeps it in the image on every build type. target_link_options(libproot-loader.so PRIVATE -static -nostdlib -no-pie + -Wl,--undefined=pokedata_workaround -Wl,--build-id=none,-Ttext=0x2000000000,--rosegment,-z,noexecstack) # tracee/mem.c finds the loader's pokedata_workaround stub by its offset from _start. diff --git a/app/src/main/cpp/wn-steam-client/CMakeLists.txt b/app/src/main/cpp/wn-steam-client/CMakeLists.txt index 949f00483..ac196d6df 100644 --- a/app/src/main/cpp/wn-steam-client/CMakeLists.txt +++ b/app/src/main/cpp/wn-steam-client/CMakeLists.txt @@ -56,6 +56,10 @@ set(WNSTEAM_RUST_OUT "${CMAKE_LIBRARY_OUTPUT_DIRECTORY}/libwnsteam.so") set(WNSTEAM_RUST_LINKER "${ANDROID_NDK}/toolchains/llvm/prebuilt/${WNSTEAM_NDK_HOST_TAG}/bin/${WNSTEAM_RUST_TARGET}${WNSTEAM_ANDROID_API}-clang${WNSTEAM_LINKER_SUFFIX}") +# Android 15+ ships 16 KB page-size kernels. Cargo drives the NDK linker itself +# and therefore does not inherit Gradle's CMake linker flags, so ask for 16 KB +# LOAD alignment explicitly here; otherwise libwnsteam.so would be the only ELF +# in the APK still built for 4 KB pages. add_custom_command( OUTPUT "${WNSTEAM_RUST_OUT}" COMMAND "${CMAKE_COMMAND}" -E env @@ -64,6 +68,7 @@ add_custom_command( "AR_aarch64_linux_android=${ANDROID_NDK}/toolchains/llvm/prebuilt/${WNSTEAM_NDK_HOST_TAG}/bin/llvm-ar${WNSTEAM_AR_SUFFIX}" "${CARGO_EXECUTABLE}" rustc --release --target "${WNSTEAM_RUST_TARGET}" --lib -- -Clink-arg=-Wl,-soname,libwnsteam.so + -Clink-arg=-Wl,-z,max-page-size=16384 COMMAND "${CMAKE_COMMAND}" -E copy_if_different "${WNSTEAM_RUST_LIB}" "${WNSTEAM_RUST_OUT}"