#!/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" 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" 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_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 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" ] } 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 \ '*.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 } 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 } 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= } 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 head="$(git -C "$GIT_SRC_DIR" rev-parse HEAD)" tracked_changes="$(git -C "$GIT_SRC_DIR" diff --binary HEAD -- | git hash-object --stdin)" printf '%s:%s\n' "$head" "$tracked_changes" } 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 } 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_bundle_manpages="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_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_bundle_manpages="true" ;; --test) gradle_task="testDebugUnitTest" artifact_label="debug unit tests" should_compile_host_git="true" ;; --compile-git) build_all_git_targets return ;; *) return ;; esac setup_env if [ "$should_bump_version" = "true" ]; then bump_android_version fi if [ "$should_compile_host_git" = "true" ]; then build_host_git export GITHUG_TEST_GIT_BINARY="$HOST_GIT_BINARY" fi if [ "$should_bundle_manpages" = "true" ]; then ensure_git_source bundle_git_manpages fi log "Running $artifact_label with --no-daemon" "$PROJECT_DIR/gradlew" --no-daemon "$gradle_task" if [ -n "$artifact_source" ] && [ -f "$artifact_source" ]; then log "Renaming $(basename "$artifact_source") to $(basename "$artifact_target")" mv -f "$artifact_source" "$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 } print_usage() { cat <&2 print_usage >&2 exit 1 ;; esac } main() { validate_args "${1:-}" require_tool curl require_tool unzip require_tool tar require_tool git require_tool perl require_tool make require_tool clang require_tool pkg-config require_tool readelf require_tool file ensure_dir "$TMP_DIR" ensure_dir "$SDK_DIR" ensure_dir "$GRADLE_USER_HOME_DIR" ensure_jdk17 ensure_cmdline_tools ensure_wrapper_jar write_local_properties ensure_sdk_packages maybe_run_operation "${1:-}" log "Environment ready" log "Project dir: $PROJECT_DIR" log "JDK dir: $JDK_DIR" log "SDK dir: $SDK_DIR" log "NDK dir: $ANDROID_NDK_DIR" log "CMake dir: $ANDROID_CMAKE_DIR" log "To compile Git for host and Android ABIs: bash ./AndroidProjectTooling.sh --compile-git" log "To build without a background Gradle daemon: ./gradlew --no-daemon assembleDebug" log "To set up and build in one step: bash ./AndroidProjectTooling.sh --build" log "To set up and build a release AAB in one step: bash ./AndroidProjectTooling.sh --build-release-aab" log "To set up and run unit tests with compiled host Git: bash ./AndroidProjectTooling.sh --test" } main "$@"