#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$SCRIPT_DIR" TMP_DIR="$PROJECT_DIR/.tmp-android-build" SDK_DIR="$PROJECT_DIR/android-sdk" JDK_DIR="$PROJECT_DIR/jdk" GRADLE_USER_HOME_DIR="$PROJECT_DIR/.gradle-user-home" STATE_FILE="$PROJECT_DIR/.android-project-tooling.state" LEGACY_STATE_FILE="$PROJECT_DIR/.setup-build-environment.state" COMMIT_SUMMARY_FILE="$PROJECT_DIR/commit-summary.txt" UPLOAD_SCRIPT="$PROJECT_DIR/upload2DL.sh" CMDLINE_TOOLS_DIR="$SDK_DIR/cmdline-tools" CMDLINE_TOOLS_LATEST_DIR="$CMDLINE_TOOLS_DIR/latest" WRAPPER_JAR_PATH="$PROJECT_DIR/gradle/wrapper/gradle-wrapper.jar" LOCAL_PROPERTIES_PATH="$PROJECT_DIR/local.properties" HOST_GIT_BINARY="$PROJECT_DIR/build/host-git/libgit.so" GIT_SRC_DIR="$TMP_DIR/git-src" HOST_GIT_DIR="$PROJECT_DIR/build/host-git" HOST_GIT_STAMP="$HOST_GIT_DIR/.source-fingerprint" ANDROID_JNI_DIR="$PROJECT_DIR/app/src/main/jniLibs" ANDROID_ASSET_MANPAGE_DIR="$PROJECT_DIR/app/src/main/assets/manpages" GIT_PATCH_DIR="$PROJECT_DIR/patches/git" ANDROID_API=24 ANDROID_CMDLINE_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" GRADLE_DIST_URL="https://services.gradle.org/distributions/gradle-8.7-bin.zip" JDK_DIST_URL="https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/eclipse" ANDROID_NDK_PACKAGE="ndk;27.2.12479018" ANDROID_CMAKE_PACKAGE="cmake;3.22.1" ANDROID_EMULATOR_SYSTEM_IMAGE="system-images;android-35;google_apis;x86_64" ANDROID_TEST_AVD_NAME="githug_android_api35" ANDROID_NDK_DIR="$SDK_DIR/ndk/27.2.12479018" ANDROID_CMAKE_DIR="$SDK_DIR/cmake/3.22.1" ANDROID_TOOLBIN="$ANDROID_NDK_DIR/toolchains/llvm/prebuilt/linux-x86_64/bin" STATE_TTL_SECONDS=86400 HIDE_EMULATOR_WINDOW="false" log() { printf '\n[%s] %s\n' "tooling" "$1" } require_tool() { if ! command -v "$1" >/dev/null 2>&1; then echo "Missing required tool: $1" >&2 exit 1 fi } ensure_dir() { mkdir -p "$1" } current_epoch() { date +%s } have_recent_success_marker() { local marker_file="$STATE_FILE" if [ ! -f "$marker_file" ] && [ -f "$LEGACY_STATE_FILE" ]; then marker_file="$LEGACY_STATE_FILE" fi if [ ! -f "$marker_file" ]; then return 1 fi local recorded_epoch recorded_epoch="$(cat "$marker_file" 2>/dev/null || true)" if ! printf '%s' "$recorded_epoch" | grep -Eq '^[0-9]+$'; then return 1 fi local now now="$(current_epoch)" [ $((now - recorded_epoch)) -lt "$STATE_TTL_SECONDS" ] } mark_successful_check() { current_epoch > "$STATE_FILE" } required_sdk_packages_present() { [ -d "$SDK_DIR/platform-tools" ] \ && [ -d "$SDK_DIR/platforms/android-35" ] \ && [ -d "$SDK_DIR/build-tools/35.0.0" ] \ && [ -d "$ANDROID_NDK_DIR" ] \ && [ -d "$ANDROID_CMAKE_DIR" ] } required_emulator_packages_present() { required_sdk_packages_present \ && [ -x "$SDK_DIR/emulator/emulator" ] \ && [ -d "$SDK_DIR/system-images/android-35/google_apis/x86_64" ] } auto_commit_if_needed() { if ! command -v git >/dev/null 2>&1; then log "Git is not available; skipping automatic commit" return fi if ! git -C "$PROJECT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then log "Project is not inside a git work tree; skipping automatic commit" return fi git -C "$PROJECT_DIR" add -A # Defense in depth: never allow signing material or local signing config to be # included in the automatic commit, even if a file was force-added manually. git -C "$PROJECT_DIR" reset -q HEAD -- \ keystore.properties \ commit-summary.txt \ upload2DL.sh \ '*.keystore' \ '*.jks' 2>/dev/null || true if git -C "$PROJECT_DIR" diff --cached --quiet; then log "No source changes to commit after successful build" return fi log "Creating git commit for the latest successful build" if [ -s "$COMMIT_SUMMARY_FILE" ] && grep -q '[^[:space:]]' "$COMMIT_SUMMARY_FILE"; then git -C "$PROJECT_DIR" commit -F "$COMMIT_SUMMARY_FILE" else git -C "$PROJECT_DIR" commit -m "Misc Changes" fi : > "$COMMIT_SUMMARY_FILE" git push } download_if_missing() { local url="$1" local output="$2" if [ -f "$output" ]; then log "Using existing download: ${output#$PROJECT_DIR/}" else log "Downloading $(basename "$output")" curl -L "$url" -o "$output" fi } ensure_cmdline_tools() { if [ -x "$CMDLINE_TOOLS_LATEST_DIR/bin/sdkmanager" ]; then log "Android command-line tools already present" return fi ensure_dir "$CMDLINE_TOOLS_DIR" ensure_dir "$TMP_DIR" local zip_path="$TMP_DIR/commandlinetools.zip" download_if_missing "$ANDROID_CMDLINE_TOOLS_URL" "$zip_path" log "Extracting Android command-line tools" rm -rf "$CMDLINE_TOOLS_LATEST_DIR" unzip -q -o "$zip_path" -d "$CMDLINE_TOOLS_DIR" if [ -d "$CMDLINE_TOOLS_DIR/cmdline-tools" ]; then mv "$CMDLINE_TOOLS_DIR/cmdline-tools" "$CMDLINE_TOOLS_LATEST_DIR" fi } ensure_wrapper_jar() { if [ -f "$WRAPPER_JAR_PATH" ]; then log "Gradle wrapper jar already present" return fi ensure_dir "$TMP_DIR" local zip_path="$TMP_DIR/gradle-8.7-bin.zip" local gradle_extract_dir="$TMP_DIR/gradle-8.7" local plugin_wrapper_jar="$gradle_extract_dir/lib/plugins/gradle-wrapper-8.7.jar" download_if_missing "$GRADLE_DIST_URL" "$zip_path" log "Extracting Gradle wrapper jar" rm -rf "$gradle_extract_dir" unzip -q -o "$zip_path" -d "$TMP_DIR" python - <&2 exit 1 fi tar -xzf "$archive_path" -C "$extract_dir" local extracted_root extracted_root="$(find "$extract_dir" -mindepth 1 -maxdepth 1 -type d | head -n 1)" if [ -z "$extracted_root" ]; then echo "Unable to locate extracted JDK directory" >&2 exit 1 fi mv "$extracted_root" "$JDK_DIR" } setup_java_env() { export JAVA_HOME="$JDK_DIR" export PATH="$JAVA_HOME/bin:$PATH" } write_local_properties() { log "Writing local.properties" cat > "$LOCAL_PROPERTIES_PATH" <&2 exit 1 fi if [ ! -f "$UPLOAD_SCRIPT" ]; then log "No upload2DL.sh found; skipping artifact upload" return fi log "Uploading $(basename "$artifact_path") with upload2DL.sh" bash "$UPLOAD_SCRIPT" "$artifact_path" } setup_env() { setup_java_env export ANDROID_HOME="$SDK_DIR" export ANDROID_SDK_ROOT="$SDK_DIR" export GRADLE_USER_HOME="$GRADLE_USER_HOME_DIR" } require_path() { if [ ! -e "$1" ]; then echo "Required path not found: $1" >&2 exit 1 fi } ensure_git_source() { mkdir -p "$TMP_DIR" if [ ! -d "$GIT_SRC_DIR/.git" ]; then log "Cloning Git source" git clone --depth 1 https://github.com/git/git.git "$GIT_SRC_DIR" else log "Using existing Git source checkout" fi restore_retired_git_patch_changes apply_git_patches } restore_retired_git_patch_changes() { local common_main="$GIT_SRC_DIR/common-main.c" if [ ! -f "$common_main" ]; then return fi if grep -q 'githug_git_main' "$common_main" && ! find "$GIT_PATCH_DIR" -maxdepth 1 -type f -name '*githug*git*main*.patch' 2>/dev/null | grep -q .; then log "Restoring retired Git patch changes from common-main.c" git -C "$GIT_SRC_DIR" checkout -- common-main.c fi } apply_git_patches() { if [ ! -d "$GIT_PATCH_DIR" ]; then return fi local patch_file while IFS= read -r patch_file; do if git -C "$GIT_SRC_DIR" apply --reverse --check "$patch_file" >/dev/null 2>&1; then log "Git patch already applied: ${patch_file#$PROJECT_DIR/}" else log "Applying Git patch: ${patch_file#$PROJECT_DIR/}" git -C "$GIT_SRC_DIR" apply "$patch_file" fi done < <(find "$GIT_PATCH_DIR" -maxdepth 1 -type f -name '*.patch' | sort) } common_git_make_args() { printf '%s\n' \ NO_OPENSSL=YesPlease \ NO_CURL=YesPlease \ NO_EXPAT=YesPlease \ NO_GETTEXT=YesPlease \ NO_TCLTK=YesPlease \ NO_PERL=YesPlease \ NO_PYTHON=YesPlease \ NO_INSTALL_HARDLINKS=YesPlease \ NO_ICONV=YesPlease \ NO_REGEX=NeedsStartEnd \ HAVE_ALLOCA_H=YesPlease \ HAVE_PATHS_H=YesPlease \ HAVE_CLOCK_GETTIME=YesPlease \ HAVE_CLOCK_MONOTONIC=YesPlease \ HAVE_GETDELIM=YesPlease \ FREAD_READS_DIRECTORIES=UnfortunatelyYes \ CSPRNG_METHOD= \ LDFLAGS=-Wl,--export-dynamic } bundle_git_manpages() { require_path "$GIT_SRC_DIR/Documentation" log "Bundling full Git manpage sources" mkdir -p "$ANDROID_ASSET_MANPAGE_DIR" find "$ANDROID_ASSET_MANPAGE_DIR" -type f -name '*.txt' -delete while IFS= read -r manpage_source; do local manpage_name manpage_name="$(basename "$manpage_source")" cp "$manpage_source" "$ANDROID_ASSET_MANPAGE_DIR/${manpage_name%.*}.txt" done < <(find "$GIT_SRC_DIR/Documentation" -maxdepth 1 -type f \( -name 'git*.txt' -o -name 'git*.adoc' -o -name 'gitignore.txt' -o -name 'gitignore.adoc' \)) } git_source_fingerprint() { ensure_git_source local head local tracked_changes local make_args head="$(git -C "$GIT_SRC_DIR" rev-parse HEAD)" tracked_changes="$(git -C "$GIT_SRC_DIR" diff --binary HEAD -- | git hash-object --stdin)" make_args="$(common_git_make_args | git hash-object --stdin)" printf '%s:%s:%s\n' "$head" "$tracked_changes" "$make_args" } target_is_current() { local output="$1" local stamp="$2" local fingerprint="$3" [ -x "$output" ] \ && [ -f "$stamp" ] \ && [ "$(cat "$stamp" 2>/dev/null || true)" = "$fingerprint" ] } mark_target_current() { local stamp="$1" local fingerprint="$2" mkdir -p "$(dirname "$stamp")" printf '%s\n' "$fingerprint" > "$stamp" } build_host_git() { ensure_git_source bundle_git_manpages local fingerprint fingerprint="$(git_source_fingerprint):host" if target_is_current "$HOST_GIT_BINARY" "$HOST_GIT_STAMP" "$fingerprint"; then log "Host Git binary is current; skipping rebuild" "$HOST_GIT_BINARY" --version return fi log "Building Git for development host" cd "$GIT_SRC_DIR" make clean >/dev/null 2>&1 || true mapfile -t make_args < <(common_git_make_args) make -j"$(nproc 2>/dev/null || printf 4)" "${make_args[@]}" git mkdir -p "$HOST_GIT_DIR" cp git "$HOST_GIT_BINARY" chmod 755 "$HOST_GIT_BINARY" mark_target_current "$HOST_GIT_STAMP" "$fingerprint" log "Host test Git binary: $HOST_GIT_BINARY" "$HOST_GIT_BINARY" --version cd "$PROJECT_DIR" } build_android_git_for_abi() { local abi="$1" local cc="$2" local output_dir="$ANDROID_JNI_DIR/$abi" local output_binary="$output_dir/libgit.so" local stamp="$output_dir/.source-fingerprint" local fingerprint="$3:android:$abi:$cc:$ANDROID_API" require_path "$ANDROID_TOOLBIN/$cc" if target_is_current "$output_binary" "$stamp" "$fingerprint"; then log "Android $abi Git binary is current; skipping rebuild" ls -lh "$output_binary" return fi log "Building Git for Android $abi" cd "$GIT_SRC_DIR" make clean >/dev/null 2>&1 || true mapfile -t make_args < <(common_git_make_args) make -j"$(nproc 2>/dev/null || printf 4)" \ uname_S=Android \ uname_O=Android \ NO_PTHREADS=YesPlease \ NO_LIBGEN_H=YesPlease \ HAVE_DEV_TTY=YesPlease \ CC="$cc" \ AR=llvm-ar \ RANLIB=llvm-ranlib \ STRIP=llvm-strip \ "${make_args[@]}" \ git log "Validating Android $abi binary" file git readelf -h git | sed -n '1,12p' mkdir -p "$output_dir" llvm-strip -s git -o "$output_binary" chmod 755 "$output_binary" mark_target_current "$stamp" "$fingerprint" ls -lh "$output_binary" cd "$PROJECT_DIR" } build_android_git() { ensure_git_source bundle_git_manpages require_path "$ANDROID_NDK_DIR" export PATH="$ANDROID_TOOLBIN:$PATH" local fingerprint fingerprint="$(git_source_fingerprint)" build_android_git_for_abi "arm64-v8a" "aarch64-linux-android${ANDROID_API}-clang" "$fingerprint" build_android_git_for_abi "armeabi-v7a" "armv7a-linux-androideabi${ANDROID_API}-clang" "$fingerprint" build_android_git_for_abi "x86" "i686-linux-android${ANDROID_API}-clang" "$fingerprint" build_android_git_for_abi "x86_64" "x86_64-linux-android${ANDROID_API}-clang" "$fingerprint" } build_all_git_targets() { build_host_git build_android_git } ensure_sdk_packages() { setup_env if required_sdk_packages_present; then if have_recent_success_marker; then log "Skipping SDK verification/install because all components were confirmed within the last 24 hours" else log "Required SDK packages are already installed locally" mark_successful_check fi return fi if have_recent_success_marker; then log "Skipping SDK verification/install because all components were confirmed within the last 24 hours" return fi log "Accepting Android SDK licenses" set +e set +o pipefail yes | "$CMDLINE_TOOLS_LATEST_DIR/bin/sdkmanager" --sdk_root="$SDK_DIR" --licenses >/dev/null local license_status=$? set -o pipefail set -e if [ "$license_status" -ne 0 ]; then echo "sdkmanager --licenses failed with exit code $license_status" >&2 exit "$license_status" fi log "Installing required Android SDK packages" "$CMDLINE_TOOLS_LATEST_DIR/bin/sdkmanager" --sdk_root="$SDK_DIR" \ "platform-tools" \ "platforms;android-35" \ "build-tools;35.0.0" \ "$ANDROID_NDK_PACKAGE" \ "$ANDROID_CMAKE_PACKAGE" mark_successful_check } ensure_emulator_sdk_packages() { setup_env if required_emulator_packages_present; then log "Android emulator SDK packages are already installed locally" return fi log "Accepting Android SDK licenses" set +e set +o pipefail yes | "$CMDLINE_TOOLS_LATEST_DIR/bin/sdkmanager" --sdk_root="$SDK_DIR" --licenses >/dev/null local license_status=$? set -o pipefail set -e if [ "$license_status" -ne 0 ]; then echo "sdkmanager --licenses failed with exit code $license_status" >&2 exit "$license_status" fi log "Installing Android emulator SDK packages" "$CMDLINE_TOOLS_LATEST_DIR/bin/sdkmanager" --sdk_root="$SDK_DIR" \ "platform-tools" \ "platforms;android-35" \ "build-tools;35.0.0" \ "$ANDROID_NDK_PACKAGE" \ "$ANDROID_CMAKE_PACKAGE" \ "emulator" \ "$ANDROID_EMULATOR_SYSTEM_IMAGE" } ensure_test_avd() { ensure_emulator_sdk_packages if "$CMDLINE_TOOLS_LATEST_DIR/bin/avdmanager" list avd | grep -q "Name: $ANDROID_TEST_AVD_NAME"; then log "Android test AVD already present: $ANDROID_TEST_AVD_NAME" return fi log "Creating Android test AVD: $ANDROID_TEST_AVD_NAME" set +e set +o pipefail printf 'no\n' | "$CMDLINE_TOOLS_LATEST_DIR/bin/avdmanager" create avd \ --name "$ANDROID_TEST_AVD_NAME" \ --package "$ANDROID_EMULATOR_SYSTEM_IMAGE" \ --device "pixel_5" \ --force local avd_status=$? set -o pipefail set -e if [ "$avd_status" -ne 0 ]; then echo "avdmanager create avd failed with exit code $avd_status" >&2 exit "$avd_status" fi } connected_android_device() { "$SDK_DIR/platform-tools/adb" devices | awk 'NR > 1 && $2 == "device" { print $1; exit }' } wait_for_emulator_boot() { local adb="$SDK_DIR/platform-tools/adb" local boot_completed="" local attempt log "Waiting for Android emulator to boot" "$adb" wait-for-device for attempt in $(seq 1 180); do boot_completed="$("$adb" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true)" if [ "$boot_completed" = "1" ]; then "$adb" shell input keyevent 82 >/dev/null 2>&1 || true log "Android emulator booted" return fi sleep 2 done echo "Timed out waiting for Android emulator to boot" >&2 exit 1 } start_emulator_if_needed() { ensure_test_avd local existing_device existing_device="$(connected_android_device || true)" if [ -n "$existing_device" ]; then log "Using already connected Android device/emulator: $existing_device" EMULATOR_STARTED_BY_TOOLING="" return fi local emulator_log="$PROJECT_DIR/build/reports/android-emulator.log" local emulator_window_args=() if [ "$HIDE_EMULATOR_WINDOW" = "true" ]; then emulator_window_args=(-no-window) fi mkdir -p "$(dirname "$emulator_log")" log "Starting Android emulator: $ANDROID_TEST_AVD_NAME" "$SDK_DIR/emulator/emulator" \ -avd "$ANDROID_TEST_AVD_NAME" \ "${emulator_window_args[@]}" \ -no-audio \ -no-boot-anim \ -gpu swiftshader_indirect \ >"$emulator_log" 2>&1 & EMULATOR_STARTED_BY_TOOLING="$!" wait_for_emulator_boot } stop_emulator_if_started() { if [ -z "${EMULATOR_STARTED_BY_TOOLING:-}" ]; then return fi log "Stopping Android emulator started by tooling" "$SDK_DIR/platform-tools/adb" emu kill >/dev/null 2>&1 || true wait "$EMULATOR_STARTED_BY_TOOLING" >/dev/null 2>&1 || true EMULATOR_STARTED_BY_TOOLING="" } maybe_run_operation() { local mode="${1:-}" local gradle_task="" local artifact_label="" local artifact_source="" local artifact_target="" local should_bump_version="false" local should_auto_commit="false" local should_compile_host_git="false" local should_compile_android_git="false" local should_bundle_manpages="false" local should_run_emulator="false" local should_clean_gradle="false" case "$mode" in --build) gradle_task="assembleDebug" artifact_label="debug APK" artifact_source="$PROJECT_DIR/app/build/outputs/apk/debug/app-debug.apk" artifact_target="$PROJECT_DIR/app/build/outputs/apk/debug/githug-android-debug.apk" should_bump_version="true" should_auto_commit="true" should_compile_android_git="true" should_bundle_manpages="true" ;; --build-release-aab) gradle_task="bundleRelease" artifact_label="release AAB" artifact_source="$PROJECT_DIR/app/build/outputs/bundle/release/app-release.aab" artifact_target="$PROJECT_DIR/app/build/outputs/bundle/release/githug-android-release.aab" should_bump_version="true" should_auto_commit="true" should_compile_android_git="true" should_bundle_manpages="true" ;; --test) gradle_task="testDebugUnitTest" artifact_label="debug unit tests" should_compile_host_git="true" ;; --clean-test) gradle_task="testDebugUnitTest" artifact_label="clean debug unit tests" should_compile_host_git="true" should_clean_gradle="true" ;; --test-emulator) gradle_task="connectedDebugAndroidTest" artifact_label="debug instrumentation tests on Android emulator" should_compile_android_git="true" should_bundle_manpages="true" should_run_emulator="true" ;; --compile-git) build_all_git_targets return ;; *) return ;; esac setup_env if [ "$should_bump_version" = "true" ]; then bump_android_version fi if [ -n "$artifact_source" ]; then local version_code version_code="$(android_version_code)" case "$mode" in --build) artifact_target="$PROJECT_DIR/app/build/outputs/apk/debug/githug-android-debug-v${version_code}.apk" ;; --build-release-aab) artifact_target="$PROJECT_DIR/app/build/outputs/bundle/release/githug-android-release-v${version_code}.aab" ;; esac fi if [ "$should_compile_host_git" = "true" ]; then build_host_git export GITHUG_TEST_GIT_BINARY="$HOST_GIT_BINARY" fi if [ "$should_compile_android_git" = "true" ]; then build_android_git fi if [ "$should_bundle_manpages" = "true" ]; then ensure_git_source bundle_git_manpages fi if [ "$should_run_emulator" = "true" ]; then start_emulator_if_needed trap stop_emulator_if_started EXIT fi log "Running $artifact_label with --no-daemon" if [ "$should_clean_gradle" = "true" ]; then "$PROJECT_DIR/gradlew" --no-daemon clean "$gradle_task" else "$PROJECT_DIR/gradlew" --no-daemon "$gradle_task" fi if [ -n "$artifact_source" ] && [ -f "$artifact_source" ]; then log "Renaming $(basename "$artifact_source") to $(basename "$artifact_target")" mv -f "$artifact_source" "$artifact_target" upload_build_artifact_if_possible "$artifact_target" elif [ -n "$artifact_source" ]; then log "Expected build output not found for rename: ${artifact_source#$PROJECT_DIR/}" fi if [ "$should_auto_commit" = "true" ]; then auto_commit_if_needed fi log "Stopping any Gradle daemons just in case" "$PROJECT_DIR/gradlew" --stop >/dev/null 2>&1 || true stop_emulator_if_started trap - EXIT } print_usage() { cat <