diff --git a/.github/workflows/scripts-android.yml b/.github/workflows/scripts-android.yml index b1ba7def43..c1aea50547 100644 --- a/.github/workflows/scripts-android.yml +++ b/.github/workflows/scripts-android.yml @@ -1,4 +1,3 @@ ---- name: Test Android build scripts 'on': @@ -15,11 +14,59 @@ name: Test Android build scripts jobs: build-android: runs-on: ubuntu-latest + timeout-minutes: 90 + steps: - uses: actions/checkout@v4 + + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@main + with: + tool-cache: true + android: false + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: false # Changed to false - might be removing packages + + - name: Install System Dependencies + run: | + sudo apt-get update -y + sudo apt-get install -y xvfb libxrender1 libxtst6 libxi6 xmlstarlet + echo "Verifying xvfb installation:" + ls -la /usr/bin/xvfb-run + which xvfb-run + /usr/bin/xvfb-run --help || true + + - name: Additional Cleanup + run: | + sudo rm -rf /usr/local/lib/android/sdk/build-tools/* 2>/dev/null || true + sudo rm -rf /usr/local/lib/android/sdk/platforms/* 2>/dev/null || true + sudo rm -rf /usr/local/lib/android/sdk/platform-tools 2>/dev/null || true + df -h + - name: Setup workspace run: ./scripts/setup-workspace.sh -q -DskipTests + - name: Build Android port run: ./scripts/build-android-port.sh -q -DskipTests + - name: Build Hello Codename One Android app run: ./scripts/build-android-app.sh -q -DskipTests + env: + AVD_CACHE_ROOT: ${{ github.workspace }}/.android-avd + EMULATOR_BOOT_TIMEOUT_SECONDS: 1200 + PACKAGE_SERVICE_TIMEOUT_SECONDS: 1200 + FRAMEWORK_READY_PRIMARY_TIMEOUT_SECONDS: 300 + FRAMEWORK_READY_RESTART_TIMEOUT_SECONDS: 240 + EMULATOR_POST_BOOT_GRACE_SECONDS: 30 + UI_TEST_TIMEOUT_SECONDS: 1200 + + - name: Upload UI test artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: hello-codenameone-ui-test-artifacts + path: ${{ env.CN1_UI_TEST_ARTIFACT_DIR }} + if-no-files-found: warn \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0d12a7309f..107297647f 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ **/.idea/* **/build/* **/dist/* +build-artifacts/ *.zip CodenameOneDesigner/src/version.properties /Ports/iOSPort/build/ @@ -81,3 +82,4 @@ pom.xml.tag !.brokk/style.md !.brokk/review.md !.brokk/project.properties +.android-avd/ diff --git a/scripts/build-android-app.sh b/scripts/build-android-app.sh index f424546247..485eb20126 100755 --- a/scripts/build-android-app.sh +++ b/scripts/build-android-app.sh @@ -4,6 +4,16 @@ set -euo pipefail ba_log() { echo "[build-android-app] $1"; } +run_with_timeout() { + local duration="$1" + shift + if command -v timeout >/dev/null 2>&1; then + timeout "$duration" "$@" + else + "$@" + fi +} + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$REPO_ROOT" @@ -104,7 +114,7 @@ MAVEN_CMD=( ba_log "Generating Codename One application skeleton via codenameone-maven-plugin" ( cd "$WORK_DIR" - xvfb-run -a "${MAVEN_CMD[@]}" -q \ + /usr/bin/xvfb-run -a "${MAVEN_CMD[@]}" -q \ com.codenameone:codenameone-maven-plugin:7.0.204:generate-app-project \ -DgroupId="$GROUP_ID" \ -DartifactId="$ARTIFACT_ID" \ @@ -265,21 +275,1620 @@ if [ -z "$GRADLE_PROJECT_DIR" ]; then exit 1 fi -ba_log "Invoking Gradle build in $GRADLE_PROJECT_DIR" +# --- Inject instrumentation UI test into Gradle project --- +APP_MODULE_DIR=$(find "$GRADLE_PROJECT_DIR" -maxdepth 1 -type d -name "app" | head -n 1 || true) +if [ -z "$APP_MODULE_DIR" ]; then + ba_log "Unable to locate Gradle app module inside $GRADLE_PROJECT_DIR" >&2 + exit 1 +fi + +UI_TEST_TEMPLATE="$SCRIPT_DIR/templates/HelloCodenameOneUiTest.java.tmpl" +if [ ! -f "$UI_TEST_TEMPLATE" ]; then + ba_log "UI test template not found: $UI_TEST_TEMPLATE" >&2 + exit 1 +fi + +UI_TEST_DIR="$APP_MODULE_DIR/src/androidTest/java/${PACKAGE_PATH}" +mkdir -p "$UI_TEST_DIR" +UI_TEST_FILE="$UI_TEST_DIR/${MAIN_NAME}UiTest.java" + +sed -e "s|@PACKAGE@|$PACKAGE_NAME|g" \ + -e "s|@MAIN_NAME@|$MAIN_NAME|g" \ + "$UI_TEST_TEMPLATE" > "$UI_TEST_FILE" +ba_log "Created instrumentation UI test at $UI_TEST_FILE" + +STUB_SRC_DIR="$APP_MODULE_DIR/src/main/java/${PACKAGE_PATH}" +mkdir -p "$STUB_SRC_DIR" +STUB_SRC_FILE="$STUB_SRC_DIR/${MAIN_NAME}Stub.java" +if [ ! -f "$STUB_SRC_FILE" ]; then + cat >"$STUB_SRC_FILE" <"$MANIFEST_FILE" <<'EOF_MANIFEST' + + + +EOF_MANIFEST + ba_log "Created minimal Android manifest at $MANIFEST_FILE" +fi + +grep -q '# \n#' "$MANIFEST_FILE" + +# Remove deprecated package attribute and inline declarations +perl -0777 -pe 's/\s+package="[^"]*"//; s#]*/>\s*##g' -i "$MANIFEST_FILE" + +# Ensure tools namespace for tools:node annotations +if ! grep -Fq 'xmlns:tools=' "$MANIFEST_FILE"; then + perl -0777 -pe 's#]*)>##' -i "$MANIFEST_FILE" +fi + +# Normalize existing stub declarations rather than inserting new ones +python3 - "$MANIFEST_FILE" "$FQCN" "$PACKAGE_NAME" "$MAIN_NAME" <<'PY' +import re +import sys +from pathlib import Path + +manifest_path, fqcn, package_name, main_name = sys.argv[1:5] +manifest = Path(manifest_path) +text = manifest.read_text() + +text = re.sub(r'.*?', '', text, flags=re.S) + +name_pattern = re.compile( + r'(android:name=")(?:(?:%s)|(?:\.?%sStub)|(?:%s\.%sStub))"' % ( + re.escape(fqcn), re.escape(main_name), re.escape(package_name), re.escape(main_name) + ) +) +text = name_pattern.sub(r'\1%s"' % fqcn, text) + +activity_pattern = re.compile( + r']*android:name="%s"[^>]*>(?:.*?)|]*android:name="%s"[^>]*/>' % ( + re.escape(fqcn), re.escape(fqcn) + ), + flags=re.S, +) + +seen = {"value": False} + +def replace_activity(match): + body = match.group(0) + body = re.sub(r'\s+tools:node="[^"]*"', '', body) + if 'android:exported=' in body: + body = re.sub(r'android:exported="[^"]*"', 'android:exported="true"', body, count=1) + else: + close = body.find('>') + if close != -1: + body = body[:close] + ' android:exported="true"' + body[close:] + if seen["value"]: + return '' + seen["value"] = True + return body + +text = activity_pattern.sub(replace_activity, text) + +if not seen["value"]: + raise SystemExit(f"Stub activity declaration not found in manifest: {manifest_path}") + +manifest.write_text(text) +PY + +STUB_DECL_COUNT=$(grep -c "android:name=\"$FQCN\"" "$MANIFEST_FILE" || true) +ba_log "Stub activity declarations present after normalization: $STUB_DECL_COUNT" +if [ "$STUB_DECL_COUNT" -ne 1 ]; then + ba_log "Expected exactly one stub activity declaration after normalization" >&2 + sed -n '1,160p' "$MANIFEST_FILE" | sed 's/^/[build-android-app] manifest: /' + exit 1 +fi + +if [ ! -f "$STUB_SRC_FILE" ]; then + ba_log "Missing stub activity source at $STUB_SRC_FILE" >&2 + exit 1 +fi + +APP_BUILD_GRADLE="$APP_MODULE_DIR/build.gradle" +if [ ! -f "$APP_BUILD_GRADLE" ]; then + ba_log "Expected Gradle build file not found at $APP_BUILD_GRADLE" >&2 + exit 1 +fi + +if ! grep -q "android[[:space:]]*{" "$APP_BUILD_GRADLE"; then + ba_log "Gradle build file at $APP_BUILD_GRADLE is missing an android { } block" >&2 + exit 1 +fi + +ensure_gradle_package_config() { + python3 - "$APP_BUILD_GRADLE" "$PACKAGE_NAME" <<'PY' +import sys +import re +from pathlib import Path + +path = Path(sys.argv[1]) +package_name = sys.argv[2] +text = path.read_text() +original = text +messages = [] + +def ensure_application_plugin(source: str) -> str: + plugin_id = "com.android.application" + if plugin_id in source: + return source + if "plugins" in source: + updated = re.sub(r"(plugins\s*\{)", r"\1\n id \"%s\"" % plugin_id, source, count=1) + if updated != source: + messages.append(f"Applied {plugin_id} via plugins block") + return updated + messages.append(f"Applied {plugin_id} via legacy apply plugin syntax") + return f"apply plugin: \"{plugin_id}\"\n" + source + +def ensure_android_block(source: str) -> str: + if re.search(r"android\s*\{", source): + return source + messages.append("Inserted android block") + return source + "\nandroid {\n}\n" + +def ensure_namespace(source: str) -> str: + pattern = re.compile(r"\bnamespace\s+[\"']([^\"']+)[\"']") + match = pattern.search(source) + if match: + if match.group(1) != package_name: + start, end = match.span() + source = source[:start] + f"namespace \"{package_name}\"" + source[end:] + messages.append(f"Updated namespace to {package_name}") + return source + android_match = re.search(r"android\s*\{", source) + if not android_match: + raise SystemExit("Unable to locate android block when inserting namespace") + insert = android_match.end() + source = source[:insert] + f"\n namespace \"{package_name}\"" + source[insert:] + messages.append(f"Inserted namespace {package_name}") + return source + +def ensure_default_config(source: str) -> str: + if re.search(r"defaultConfig\s*\{", source): + return source + android_match = re.search(r"android\s*\{", source) + if not android_match: + raise SystemExit("Unable to locate android block when creating defaultConfig") + insert = android_match.end() + snippet = "\n defaultConfig {\n }" + messages.append("Inserted defaultConfig block") + return source[:insert] + snippet + source[insert:] + +def ensure_application_id(source: str) -> str: + pattern = re.compile(r"\bapplicationId\s+[\"']([^\"']+)[\"']") + match = pattern.search(source) + if match: + if match.group(1) != package_name: + start, end = match.span() + indent = source[:start].split("\n")[-1].split("applicationId")[0] + source = source[:start] + f"{indent}applicationId \"{package_name}\"" + source[end:] + messages.append(f"Updated applicationId to {package_name}") + return source + default_match = re.search(r"defaultConfig\s*\{", source) + if not default_match: + raise SystemExit("Unable to locate defaultConfig when inserting applicationId") + insert = default_match.end() + source = source[:insert] + f"\n applicationId \"{package_name}\"" + source[insert:] + messages.append(f"Inserted applicationId {package_name}") + return source + +def ensure_compile_sdk(source: str) -> str: + pattern = re.compile(r"\bcompileSdk(?:Version)?\s+(\d+)") + match = pattern.search(source) + desired = "compileSdk 35" + if match: + start, end = match.span() + if match.group(0) != desired: + source = source[:start] + desired + source[end:] + messages.append("Updated compileSdk to 35") + return source + android_match = re.search(r"android\s*\{", source) + if not android_match: + raise SystemExit("Unable to locate android block when inserting compileSdk") + insert = android_match.end() + source = source[:insert] + f"\n {desired}" + source[insert:] + messages.append("Inserted compileSdk 35") + return source + +def ensure_default_config_value(source: str, key: str, value: str, *, quoted: bool = False) -> str: + pattern = re.compile(rf"{key}(?:Version)?\s+[\"']?([^\"'\s]+)[\"']?") + default_match = re.search(r"defaultConfig\s*\{", source) + if not default_match: + raise SystemExit(f"Unable to locate defaultConfig when inserting {key}") + block_start = default_match.end() + block_end = _find_matching_brace(source, default_match.start()) + block = source[block_start:block_end] + match = pattern.search(block) + replacement_value = f'"{value}"' if quoted else value + replacement = f" {key} {replacement_value}" + if match: + if match.group(1) != value or "Version" in match.group(0): + start = block_start + match.start() + end = block_start + match.end() + source = source[:start] + replacement + source[end:] + messages.append(f"Updated {key} to {value}") + return source + insert = block_start + source = source[:insert] + "\n" + replacement + source[insert:] + messages.append(f"Inserted {key} {value}") + return source + +def _find_matching_brace(source: str, start: int) -> int: + depth = 0 + for index in range(start, len(source)): + char = source[index] + if char == '{': + depth += 1 + elif char == '}': + depth -= 1 + if depth == 0: + return index + raise SystemExit("Failed to locate matching brace for defaultConfig block") + +def ensure_test_instrumentation_runner(source: str) -> str: + default_match = re.search(r"defaultConfig\s*\{", source) + if not default_match: + raise SystemExit("Unable to locate defaultConfig when inserting testInstrumentationRunner") + block_start = default_match.end() + block_end = _find_matching_brace(source, default_match.start()) + block = source[block_start:block_end] + pattern = re.compile(r"testInstrumentationRunner\s+[\"']([^\"']+)[\"']") + desired = "androidx.test.runner.AndroidJUnitRunner" + match = pattern.search(block) + replacement = f" testInstrumentationRunner \"{desired}\"" + if match: + if match.group(1) != desired: + start = block_start + match.start() + end = block_start + match.end() + source = source[:start] + replacement + source[end:] + messages.append("Updated testInstrumentationRunner to AndroidJUnitRunner") + return source + insert = block_start + source = source[:insert] + "\n" + replacement + source[insert:] + messages.append("Inserted testInstrumentationRunner AndroidJUnitRunner") + return source + +text = ensure_application_plugin(text) +text = ensure_android_block(text) +text = ensure_namespace(text) +text = ensure_default_config(text) +text = ensure_application_id(text) +text = ensure_compile_sdk(text) +text = ensure_default_config_value(text, "minSdk", "19") +text = ensure_default_config_value(text, "targetSdk", "35") +text = ensure_default_config_value(text, "versionCode", "100") +text = ensure_default_config_value(text, "versionName", "1.0", quoted=True) +text = ensure_test_instrumentation_runner(text) + +if text != original: + path.write_text(text) + +for message in messages: + print(message) +PY +} + +if ! GRADLE_PACKAGE_LOG=$(ensure_gradle_package_config); then + ba_log "Failed to align namespace/applicationId with Codename One package" >&2 + exit 1 +fi +if [ -n "$GRADLE_PACKAGE_LOG" ]; then + while IFS= read -r line; do + [ -n "$line" ] && ba_log "$line" + done <<<"$GRADLE_PACKAGE_LOG" +fi + +ba_log "app/build.gradle head after package alignment:" +sed -n '1,80p' "$APP_BUILD_GRADLE" | sed 's/^/[build-android-app] app.gradle: /' + chmod +x "$GRADLE_PROJECT_DIR/gradlew" + +GRADLE_UPDATE_OUTPUT="$("$SCRIPT_DIR/update_android_ui_test_gradle.py" --package-name "$PACKAGE_NAME" "$APP_BUILD_GRADLE")" +if [ -n "$GRADLE_UPDATE_OUTPUT" ]; then + while IFS= read -r line; do + [ -n "$line" ] && ba_log "$line" + done <<<"$GRADLE_UPDATE_OUTPUT" +fi + +ba_log "Dependencies block after instrumentation update:" +awk '/^\s*dependencies\s*\{/{flag=1} flag{print} /^\s*\}/{if(flag){exit}}' "$APP_BUILD_GRADLE" \ + | sed 's/^/[build-android-app] | /' + +# Final manifest sanity before Gradle preflight +if [ -f "$MANIFEST_FILE" ]; then + tmp_manifest_pruned="$(mktemp)" + FQCN="$FQCN" perl -0777 -pe ' + my $fq = quotemeta($ENV{FQCN}); + my $seen = 0; + s{ + (]*android:name="$fq"[^>]*/>\s*) + | + (]*android:name="$fq"[^>]*>.*?\s*) + }{ + $seen++ ? "" : $& + }gsxe; + ' "$MANIFEST_FILE" >"$tmp_manifest_pruned" + mv "$tmp_manifest_pruned" "$MANIFEST_FILE" + STUB_COUNT=$(grep -c "android:name=\"$FQCN\"" "$MANIFEST_FILE" || true) + ba_log "Stub declarations in manifest after pruning: $STUB_COUNT" + ba_log "Dumping manifest contents prior to preflight" + nl -ba "$MANIFEST_FILE" | sed 's/^/[build-android-app] manifest: /' + grep -n "android:name=\"$FQCN\"" "$MANIFEST_FILE" | sed 's/^/[build-android-app] manifest-match: /' || true +fi + +ba_log "Validating manifest merge before assemble" +if ! ( + cd "$GRADLE_PROJECT_DIR" && + JAVA_HOME="$JAVA17_HOME" PATH="$JAVA17_HOME/bin:$PATH" ./gradlew --no-daemon :app:processDebugMainManifest +); then + ba_log ":app:processDebugMainManifest failed during preflight" >&2 + dump_manifest_merger_reports + exit 1 +fi + +FINAL_ARTIFACT_DIR="${CN1_TEST_SCREENSHOT_EXPORT_DIR:-$REPO_ROOT/build-artifacts}" +mkdir -p "$FINAL_ARTIFACT_DIR" +if [ -n "${GITHUB_ENV:-}" ]; then + printf 'CN1_UI_TEST_ARTIFACT_DIR=%s\n' "$FINAL_ARTIFACT_DIR" >> "$GITHUB_ENV" +fi + +ba_log "Invoking Gradle build in $GRADLE_PROJECT_DIR" ORIGINAL_JAVA_HOME="$JAVA_HOME" export JAVA_HOME="$JAVA17_HOME" +export PATH="$ANDROID_SDK_ROOT/platform-tools:$ANDROID_SDK_ROOT/emulator:$PATH" + +SDKMANAGER_BIN="" +if [ -x "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" ]; then + SDKMANAGER_BIN="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" +elif [ -x "$ANDROID_SDK_ROOT/cmdline-tools/bin/sdkmanager" ]; then + SDKMANAGER_BIN="$ANDROID_SDK_ROOT/cmdline-tools/bin/sdkmanager" +elif command -v sdkmanager >/dev/null 2>&1; then + SDKMANAGER_BIN="$(command -v sdkmanager)" +fi + +AVDMANAGER_BIN="" +if [ -x "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/avdmanager" ]; then + AVDMANAGER_BIN="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/avdmanager" +elif [ -x "$ANDROID_SDK_ROOT/cmdline-tools/bin/avdmanager" ]; then + AVDMANAGER_BIN="$ANDROID_SDK_ROOT/cmdline-tools/bin/avdmanager" +elif command -v avdmanager >/dev/null 2>&1; then + AVDMANAGER_BIN="$(command -v avdmanager)" +fi + +install_android_packages() { + local manager="$1" + if [ -z "$manager" ]; then + ba_log "sdkmanager not available; cannot install system images" >&2 + exit 1 + fi + yes | "$manager" --licenses >/dev/null 2>&1 || true + "$manager" --install \ + "platform-tools" \ + "emulator" \ + "platforms;android-35" \ + "system-images;android-35;google_apis;x86_64" >/dev/null 2>&1 || true +} + +check_disk_space() { + local target_dir="$1" + local required_mb="$2" + + ba_log "Checking disk space for AVD creation in $target_dir" + df -h "$target_dir" | sed 's/^/[build-android-app] disk: /' + + local available_mb + available_mb=$(df -m "$target_dir" | awk 'NR==2 {print $4}') + ba_log "Available space: ${available_mb} MB (required ${required_mb} MB)" + + if [ "$available_mb" -lt "$required_mb" ]; then + ba_log "Insufficient disk space detected; attempting cleanup" >&2 + cleanup_disk_space + available_mb=$(df -m "$target_dir" | awk 'NR==2 {print $4}') + ba_log "Post-cleanup available space: ${available_mb} MB" + if [ "$available_mb" -lt "$required_mb" ]; then + return 1 + fi + fi + return 0 +} + +cleanup_disk_space() { + ba_log "Cleaning up disk space before AVD creation" + if [ -n "${HOME:-}" ]; then + rm -rf "$HOME/.gradle/caches/build-cache-*" 2>/dev/null || true + rm -rf "$HOME/.m2/repository" 2>/dev/null || true + fi + if [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "$ANDROID_SDK_ROOT/system-images" ]; then + find "$ANDROID_SDK_ROOT/system-images" -mindepth 1 -maxdepth 1 ! -name "android-35" -exec rm -rf {} + 2>/dev/null || true + fi + rm -rf /tmp/cn1-* 2>/dev/null || true + df -h | sed 's/^/[build-android-app] disk-after-cleanup: /' +} + +create_avd() { + local manager="$1" + local name="$2" + local image="$3" + local avd_dir="$4" + if [ -z "$manager" ]; then + ba_log "avdmanager not available; cannot create emulator" >&2 + exit 1 + fi + mkdir -p "$avd_dir" + + local required_mb=10240 + if ! check_disk_space "$avd_dir" "$required_mb"; then + ba_log "ERROR: insufficient disk space for AVD creation (need ${required_mb} MB)" >&2 + exit 1 + fi + + local ini_file="$avd_dir/$name.ini" + local image_dir="$avd_dir/$name.avd" + ANDROID_AVD_HOME="$avd_dir" "$manager" delete avd -n "$name" >/dev/null 2>&1 || true + rm -f "$ini_file" + rm -rf "$image_dir" + if ! ANDROID_AVD_HOME="$avd_dir" "$manager" create avd -n "$name" -k "$image" --device "pixel_5" --force >/dev/null <<<'no' + then + ba_log "Failed to create Android Virtual Device $name using image $image" >&2 + find "$avd_dir" -maxdepth 2 -mindepth 1 -print | sed 's/^/[build-android-app] AVD: /' >&2 || true + exit 1 + fi + if [ ! -f "$ini_file" ]; then + ba_log "AVD $name was created but configuration file $ini_file is missing" >&2 + find "$avd_dir" -maxdepth 1 -mindepth 1 -print | sed 's/^/[build-android-app] AVD: /' >&2 || true + exit 1 + fi + configure_avd "$avd_dir" "$name" + du -sh "$image_dir" 2>/dev/null | sed 's/^/[build-android-app] AVD-size: /' || true +} + +configure_avd() { + local avd_dir="$1" + local name="$2" + local cfg="$avd_dir/$name.avd/config.ini" + if [ ! -f "$cfg" ]; then + return + fi + declare -A settings=( + ["hw.ramSize"]=4096 + ["disk.dataPartition.size"]=6144M + ["fastboot.forceColdBoot"]=yes + ["hw.bluetooth"]=no + ["hw.camera.back"]=none + ["hw.camera.front"]=none + ["hw.audioInput"]=no + ["hw.audioOutput"]=no + ["hw.cpu.ncore"]=4 + ["hw.cpu.arch"]="x86_64" + ["abi.type"]="x86_64" + ["hw.gpu.enabled"]=yes + ["hw.gpu.mode"]=auto + ["hw.keyboard"]=yes + ["hw.sensors.proximity"]=no + ["hw.sensors.magnetic_field"]=no + ["hw.sensors.orientation"]=no + ["hw.sensors.temperature"]=no + ["hw.sensors.light"]=no + ["hw.sensors.pressure"]=no + ["hw.sensors.humidity"]=no + ["hw.gps"]=no + ["showDeviceFrame"]=no + ["skin.dynamic"]=yes + ["hw.lcd.density"]=320 + ["hw.lcd.width"]=720 + ["hw.lcd.height"]=1280 + ["vm.heapSize"]=512 + ) + local key value + for key in "${!settings[@]}"; do + value="${settings[$key]}" + if grep -q "^${key}=" "$cfg" 2>/dev/null; then + sed -i "s/^${key}=.*/${key}=${value}/" "$cfg" + else + echo "${key}=${value}" >>"$cfg" + fi + done +} + +wait_for_emulator() { + local serial="$1" + + "$ADB_BIN" start-server >/dev/null + "$ADB_BIN" -s "$serial" wait-for-device + + local boot_timeout="${EMULATOR_BOOT_TIMEOUT_SECONDS:-1200}" + if ! [[ "$boot_timeout" =~ ^[0-9]+$ ]] || [ "$boot_timeout" -le 0 ]; then + boot_timeout=1200 + fi + + local log_interval="${EMULATOR_BOOT_STATUS_LOG_INTERVAL_SECONDS:-15}" + if ! [[ "$log_interval" =~ ^[0-9]+$ ]] || [ "$log_interval" -le 0 ]; then + log_interval=15 + fi + + local deadline=$((SECONDS + boot_timeout)) + local last_log=$SECONDS + + # Stage 1: wait for device to report "device" + ba_log "Stage 1: waiting for emulator $serial to report device state" + local device_online=0 + while [ $SECONDS -lt $deadline ]; do + local state + state="$($ADB_BIN -s "$serial" get-state 2>/dev/null | tr -d '\r')" + if [ "$state" = "device" ]; then + device_online=1 + break + fi + if [ $((SECONDS - last_log)) -ge $log_interval ]; then + ba_log "Waiting for emulator $serial to come online (state=${state:-})" + last_log=$SECONDS + fi + sleep 3 + done + + if [ $device_online -ne 1 ]; then + ba_log "Emulator $serial did not report device state within ${boot_timeout}s" >&2 + return 1 + fi + + # Stage 2: wait for boot properties to indicate readiness + ba_log "Stage 2: waiting for core system properties on $serial" + local system_ready=0 + last_log=$SECONDS + while [ $SECONDS -lt $deadline ]; do + local sys_boot dev_boot bootanim bootanim_exit + sys_boot="$($ADB_BIN -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r\n')" + dev_boot="$($ADB_BIN -s "$serial" shell getprop dev.bootcomplete 2>/dev/null | tr -d '\r\n')" + bootanim="$($ADB_BIN -s "$serial" shell getprop init.svc.bootanim 2>/dev/null | tr -d '\r\n')" + bootanim_exit="$($ADB_BIN -s "$serial" shell getprop service.bootanim.exit 2>/dev/null | tr -d '\r\n')" + + if { [ "$sys_boot" = "1" ] || [ "$sys_boot" = "true" ]; } && { + [ "$dev_boot" = "1" ] || [ "$dev_boot" = "true" ]; + }; then + system_ready=1 + break + fi + + if [ "$bootanim" = "stopped" ] || [ "$bootanim_exit" = "1" ]; then + system_ready=1 + ba_log "Emulator $serial reported boot animation stopped; continuing despite unset bootcomplete" + break + fi + + if [ $((SECONDS - last_log)) -ge $log_interval ]; then + ba_log "Waiting for system services on $serial (sys.boot_completed=${sys_boot:-} dev.bootcomplete=${dev_boot:-} bootanim=${bootanim:-} bootanim_exit=${bootanim_exit:-})" + last_log=$SECONDS + fi + sleep 3 + done + + if [ $system_ready -ne 1 ]; then + ba_log "Emulator $serial failed to finish boot sequence within ${boot_timeout}s" >&2 + return 1 + fi + + # Stage 3: baseline device configuration + ba_log "Stage 3: configuring emulator $serial for UI testing" + "$ADB_BIN" -s "$serial" shell settings put global window_animation_scale 0 >/dev/null 2>&1 || true + "$ADB_BIN" -s "$serial" shell settings put global transition_animation_scale 0 >/dev/null 2>&1 || true + "$ADB_BIN" -s "$serial" shell settings put global animator_duration_scale 0 >/dev/null 2>&1 || true + "$ADB_BIN" -s "$serial" shell input keyevent 82 >/dev/null 2>&1 || true + "$ADB_BIN" -s "$serial" shell wm dismiss-keyguard >/dev/null 2>&1 || true + + return 0 +} + +wait_for_package_service() { + local serial="$1" + local timeout="${PACKAGE_SERVICE_TIMEOUT_SECONDS:-1200}" + local per_try="${PACKAGE_SERVICE_PER_TRY_TIMEOUT_SECONDS:-15}" + if ! [[ "$timeout" =~ ^[0-9]+$ ]] || [ "$timeout" -le 0 ]; then + timeout=1200 + fi + if ! [[ "$per_try" =~ ^[0-9]+$ ]] || [ "$per_try" -le 0 ]; then + per_try=15 + fi + + local deadline=$((SECONDS + timeout)) + local last_log=$SECONDS + local restart_count=0 + local max_restarts=5 + + ba_log "Waiting for package manager service on $serial" + + while [ $SECONDS -lt $deadline ]; do + local sys_boot system_server pm_ready pm_list + + sys_boot="$($ADB_BIN -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" + system_server="$(run_with_timeout "$per_try" "$ADB_BIN" -s "$serial" shell pidof system_server 2>/dev/null | tr -d '\r' || true)" + + pm_ready=0 + if run_with_timeout "$per_try" "$ADB_BIN" -s "$serial" shell pm path android >/dev/null 2>&1 \ + || run_with_timeout "$per_try" "$ADB_BIN" -s "$serial" shell cmd package path android >/dev/null 2>&1; then + pm_ready=1 + fi + + pm_list=0 + if run_with_timeout "$per_try" "$ADB_BIN" -s "$serial" shell pm list packages >/dev/null 2>&1; then + pm_list=1 + fi + + if [ $pm_ready -eq 1 ] && [ $pm_list -eq 1 ] && [ -n "$system_server" ]; then + ba_log "Package manager ready on $serial (system_server=$system_server)" + return 0 + fi + + local service_status + service_status="$(run_with_timeout "$per_try" "$ADB_BIN" -s "$serial" shell service check package 2>/dev/null | tr -d '\r' | tr -d '\n' || true)" + if [ -n "$service_status" ] && printf '%s' "$service_status" | grep -q "found"; then + if [ $pm_ready -eq 1 ] && [ -n "$system_server" ]; then + ba_log "Package service responding despite limited signals; continuing" + return 0 + fi + fi + + if [ $((SECONDS - last_log)) -ge 20 ]; then + ba_log "PM status on $serial: boot=${sys_boot:-?} system_server=${system_server:-} pm_ready=$pm_ready pm_list=$pm_list" + last_log=$SECONDS + fi + + local elapsed=$((SECONDS - (deadline - timeout))) + if [ "$sys_boot" = "1" ] && [ $pm_ready -eq 0 ] && [ $elapsed -gt 0 ] && [ $((elapsed % 180)) -eq 0 ] \ + && [ $restart_count -lt $max_restarts ]; then + restart_count=$((restart_count + 1)) + ba_log "Package manager not ready after ${elapsed}s; restarting framework (attempt ${restart_count}/${max_restarts})" + "$ADB_BIN" -s "$serial" shell stop >/dev/null 2>&1 || true + sleep 5 + "$ADB_BIN" -s "$serial" shell start >/dev/null 2>&1 || true + sleep 15 + continue + fi + + sleep 3 + done + + ba_log "Package manager service not ready on $serial after ${timeout}s" >&2 + return 1 +} + +optimize_api35_after_boot() { + local serial="$1" + ba_log "Optimizing emulator $serial for headless testing" + + run_with_timeout 30 "$ADB_BIN" -s "$serial" shell pm disable-user com.google.android.googlequicksearchbox >/dev/null 2>&1 || true + run_with_timeout 30 "$ADB_BIN" -s "$serial" shell pm disable-user com.android.vending >/dev/null 2>&1 || true + run_with_timeout 30 "$ADB_BIN" -s "$serial" shell pm disable-user com.google.android.gms >/dev/null 2>&1 || true + run_with_timeout 30 "$ADB_BIN" -s "$serial" shell cmd package bg-dexopt-job >/dev/null 2>&1 || true + run_with_timeout 30 "$ADB_BIN" -s "$serial" shell logcat -c >/dev/null 2>&1 || true + run_with_timeout 30 "$ADB_BIN" -s "$serial" shell locksettings set-disabled true >/dev/null 2>&1 || true + run_with_timeout 30 "$ADB_BIN" -s "$serial" shell svc power stayon true >/dev/null 2>&1 || true + run_with_timeout 30 "$ADB_BIN" -s "$serial" shell wm dismiss-keyguard >/dev/null 2>&1 || true + run_with_timeout 30 "$ADB_BIN" -s "$serial" shell am kill-all >/dev/null 2>&1 || true + sleep 10 +} + +wait_for_api_level() { + local serial="$1" + local timeout="${API_LEVEL_TIMEOUT_SECONDS:-600}" + local per_try="${API_LEVEL_PER_TRY_TIMEOUT_SECONDS:-5}" + if ! [[ "$timeout" =~ ^[0-9]+$ ]] || [ "$timeout" -le 0 ]; then + timeout=600 + fi + if ! [[ "$per_try" =~ ^[0-9]+$ ]] || [ "$per_try" -le 0 ]; then + per_try=5 + fi + + local deadline=$((SECONDS + timeout)) + local last_log=$SECONDS + local sdk="" + + while [ $SECONDS -lt $deadline ]; do + if sdk="$(timeout "$per_try" "$ADB_BIN" -s "$serial" shell getprop ro.build.version.sdk 2>/dev/null | tr -d '\r' | tr -d '\n')"; then + if [[ "$sdk" =~ ^[0-9]+$ ]]; then + ba_log "Device API level is $sdk" + return 0 + fi + fi + if [ $((SECONDS - last_log)) -ge 10 ]; then + ba_log "Waiting for ro.build.version.sdk on $serial" + last_log=$SECONDS + fi + sleep 2 + done + + ba_log "ro.build.version.sdk not available after ${timeout}s" >&2 + return 1 +} + +adb_framework_ready_once() { + local serial="$1" + local per_try="$2" + local phase_timeout="$3" + local log_interval="${FRAMEWORK_READY_STATUS_LOG_INTERVAL_SECONDS:-10}" + + if ! [[ "$phase_timeout" =~ ^[0-9]+$ ]] || [ "$phase_timeout" -le 0 ]; then + phase_timeout=180 + fi + if ! [[ "$per_try" =~ ^[0-9]+$ ]] || [ "$per_try" -le 0 ]; then + per_try=5 + fi + if ! [[ "$log_interval" =~ ^[0-9]+$ ]] || [ "$log_interval" -le 0 ]; then + log_interval=10 + fi + + local start_time=$SECONDS + local deadline=$((SECONDS + phase_timeout)) + local last_log=$SECONDS + local last_home_nudge=$SECONDS + + while [ $SECONDS -lt $deadline ]; do + local boot_ok dev_boot system_pid pm_ok pm_list_ok service_ok cmd_ok activity_ok resolve_ok service_status + boot_ok="$($ADB_BIN -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" + dev_boot="$($ADB_BIN -s "$serial" shell getprop dev.bootcomplete 2>/dev/null | tr -d '\r')" + system_pid="$(run_with_timeout "$per_try" "$ADB_BIN" -s "$serial" shell pidof system_server 2>/dev/null | tr -d '\r' || true)" + + pm_ok=0 + if run_with_timeout "$per_try" "$ADB_BIN" -s "$serial" shell pm path android >/dev/null 2>&1; then + pm_ok=1 + fi + + pm_list_ok=0 + if run_with_timeout "$per_try" "$ADB_BIN" -s "$serial" shell pm list packages -f >/dev/null 2>&1; then + pm_list_ok=1 + fi + + cmd_ok=0 + if run_with_timeout "$per_try" "$ADB_BIN" -s "$serial" shell cmd -l >/dev/null 2>&1; then + cmd_ok=1 + fi + + resolve_ok=0 + if run_with_timeout "$per_try" "$ADB_BIN" -s "$serial" shell \ + "cmd package resolve-activity --brief android.intent.action.MAIN -c android.intent.category.HOME" >/dev/null 2>&1; then + resolve_ok=1 + fi + + activity_ok=0 + if run_with_timeout "$per_try" "$ADB_BIN" -s "$serial" shell cmd activity top >/dev/null 2>&1; then + activity_ok=1 + fi + + service_ok=0 + service_status="$(run_with_timeout "$per_try" "$ADB_BIN" -s "$serial" shell service check package 2>/dev/null | tr -d '\r' || true)" + if [ -n "$service_status" ] && printf '%s' "$service_status" | grep -q "found"; then + service_ok=1 + fi + + # Success conditions - prioritize package manager over activity manager for API 35 + if [ -n "system_pid" ] && [ $pm_ok -eq 1 ] && [ $pm_list_ok -eq 1 ] && [ $service_ok -eq 1 ]; then + ba_log "Android framework ready on $serial (system_server=$system_pid; package manager operational)" + return 0 + fi + + # Fallback: if boot is complete and PM works, proceed even without full activity manager + if [ "$sys_boot" = "1" ] && [ $pm_ok -eq 1 ] && [ $pm_list_ok -eq 1 ]; then + local time_waiting=$((SECONDS - start_time)) + if [ $time_waiting -ge 120 ]; then + ba_log "Android framework heuristically ready on $serial after ${time_waiting}s (system_server=$system_pid; pm functional but activity_manager slow)" + return 0 + fi + fi + + # Original stricter check for early success + if [ "$boot_ok" = "1" ] && [ "$dev_boot" = "1" ] && [ -n "$system_pid" ] \ + && [ $pm_ok -eq 1 ] && [ $cmd_ok -eq 1 ] && [ $service_ok -eq 1 ]; then + if [ $pm_list_ok -eq 1 ] || [ $activity_ok -eq 1 ] || [ $resolve_ok -eq 1 ]; then ba_log "Android framework ready on $serial (system_server=$system_pid)" + return 0 + fi + + local secondary_wait="${FRAMEWORK_READY_SECONDARY_GRACE_SECONDS:-90}" + if ! [[ "$secondary_wait" =~ ^[0-9]+$ ]]; then + secondary_wait=90 + fi + if [ $((SECONDS - start_time)) -ge "$secondary_wait" ]; then + ba_log "Android framework heuristically ready on $serial (system_server=$system_pid; pm_list=$pm_list_ok activity=$activity_ok resolve=$resolve_ok)" + return 0 + fi + fi + + if [ $((SECONDS - last_log)) -ge $log_interval ]; then + ba_log "Waiting for Android framework on $serial (system_server=${system_pid:-down} boot_ok=${boot_ok:-?}/${dev_boot:-?} pm_ready=$pm_ok pm_list_ready=$pm_list_ok cmd_ready=$cmd_ok activity_ready=$activity_ok package_service_ready=$service_ok resolve_ready=$resolve_ok)" + last_log=$SECONDS + fi + + local home_retry="${FRAMEWORK_READY_HOME_RETRY_SECONDS:-30}" + if ! [[ "$home_retry" =~ ^[0-9]+$ ]]; then + home_retry=30 + fi + if [ $((SECONDS - last_home_nudge)) -ge "$home_retry" ]; then + run_with_timeout "$per_try" "$ADB_BIN" -s "$serial" shell input keyevent 82 >/dev/null 2>&1 || true + run_with_timeout "$per_try" "$ADB_BIN" -s "$serial" shell \ + am start -a android.intent.action.MAIN -c android.intent.category.HOME >/dev/null 2>&1 || true + last_home_nudge=$SECONDS + fi + sleep 2 + done + + return 1 +} + +adb_wait_framework_ready() { + local serial="$1" + "$ADB_BIN" -s "$serial" wait-for-device >/dev/null 2>&1 || return 1 + + local per_try="${FRAMEWORK_READY_PER_TRY_TIMEOUT_SECONDS:-5}" + + if adb_framework_ready_once "$serial" "$per_try" "${FRAMEWORK_READY_PRIMARY_TIMEOUT_SECONDS:-180}"; then + return 0 + fi + + ba_log "Framework not ready on $serial; restarting system services" + "$ADB_BIN" -s "$serial" shell stop >/dev/null 2>&1 || true + sleep 2 + "$ADB_BIN" -s "$serial" shell start >/dev/null 2>&1 || true + + if adb_framework_ready_once "$serial" "$per_try" "${FRAMEWORK_READY_RESTART_TIMEOUT_SECONDS:-120}"; then + return 0 + fi + + ba_log "Framework still unavailable on $serial; rebooting device" + "$ADB_BIN" -s "$serial" reboot >/dev/null 2>&1 || return 1 + "$ADB_BIN" -s "$serial" wait-for-device >/dev/null 2>&1 || return 1 + + if adb_framework_ready_once "$serial" "$per_try" "${FRAMEWORK_READY_REBOOT_TIMEOUT_SECONDS:-180}"; then + return 0 + fi + + ba_log "ERROR: Android framework/package manager not available on $serial" >&2 + return 1 +} + +dump_emulator_diagnostics() { + "$ADB_BIN" -s "$EMULATOR_SERIAL" shell getprop | sed 's/^/[build-android-app] getprop: /' || true + "$ADB_BIN" -s "$EMULATOR_SERIAL" shell logcat -d -t 2000 \ + | grep -v -E 'com\\.android\\.bluetooth|BtGd|bluetooth' \ + | tail -n 200 | sed 's/^/[build-android-app] logcat: /' || true +} + +log_instrumentation_state() { + "$ADB_BIN" -s "$EMULATOR_SERIAL" shell pm path android | sed 's/^/[build-android-app] pm path android: /' || true + + local runtime_pkg="${RUNTIME_PACKAGE:-$PACKAGE_NAME}" + local test_pkg="${TEST_RUNTIME_PACKAGE:-${runtime_pkg}.test}" + + local instrumentation_list + instrumentation_list="$("$ADB_BIN" -s "$EMULATOR_SERIAL" shell pm list instrumentation 2>/dev/null || true)" + if [ -n "$instrumentation_list" ]; then + printf '%s\n' "$instrumentation_list" | sed 's/^/[build-android-app] instrumentation: /' + else + ba_log "No instrumentation targets reported on $EMULATOR_SERIAL before installation" + fi + + local have_test_apk=0 + if [ -n "$test_pkg" ] && "$ADB_BIN" -s "$EMULATOR_SERIAL" shell pm path "$test_pkg" >/dev/null 2>&1; then + have_test_apk=1 + "$ADB_BIN" -s "$EMULATOR_SERIAL" shell pm path "$test_pkg" \ + | sed 's/^/[build-android-app] test-apk: /' + else + ba_log "Test APK for $test_pkg not yet installed on $EMULATOR_SERIAL" + fi + + local package_regex package_list package_matches + package_regex="${runtime_pkg//./\.}" + package_list="$("$ADB_BIN" -s "$EMULATOR_SERIAL" shell pm list packages 2>/dev/null || true)" + if [ -n "$package_list" ]; then + package_matches="$(printf '%s\n' "$package_list" | grep -E "${package_regex}|${package_regex}\.test" || true)" + if [ -n "$package_matches" ]; then + printf '%s\n' "$package_matches" | sed 's/^/[build-android-app] package: /' + else + ba_log "Packages matching $runtime_pkg not yet installed on $EMULATOR_SERIAL" + fi + else + ba_log "Package manager returned no packages on $EMULATOR_SERIAL" + fi +} + +collect_instrumentation_crash() { + local attempt="$1" + local crash_log="$FINAL_ARTIFACT_DIR/ui-test-crash-attempt-$attempt.log" + local latest_log="$FINAL_ARTIFACT_DIR/ui-test-crash.log" + local log_tmp filtered_tmp tomb_tmp tombstones + + log_tmp="$(mktemp "${TMPDIR:-/tmp}/ui-test-logcat.XXXXXX")" + filtered_tmp="$(mktemp "${TMPDIR:-/tmp}/ui-test-logcat-filtered.XXXXXX")" + tomb_tmp="$(mktemp "${TMPDIR:-/tmp}/ui-test-tombstones.XXXXXX")" + + : >"$crash_log" + + if "$ADB_BIN" -s "$EMULATOR_SERIAL" shell logcat -d -t 2000 >"$log_tmp" 2>/dev/null; then + if grep -E "FATAL EXCEPTION|AndroidRuntime|Process: ${RUNTIME_PACKAGE}(\\.test)?|Abort message:" "$log_tmp" >"$filtered_tmp" 2>/dev/null; then + if [ -s "$filtered_tmp" ]; then + while IFS= read -r line; do + echo "[build-android-app] crash: $line" + done <"$filtered_tmp" + cat "$filtered_tmp" >>"$crash_log" + fi + fi + fi + + tombstones="$("$ADB_BIN" -s "$EMULATOR_SERIAL" shell ls /data/tombstones 2>/dev/null | tail -n 3 || true)" + if [ -n "$tombstones" ]; then + printf '%s\n' "$tombstones" | sed 's/^/[build-android-app] tombstone: /' + printf '%s\n' "$tombstones" >"$tomb_tmp" + cat "$tomb_tmp" >>"$crash_log" + fi + + if [ -s "$crash_log" ]; then + cp "$crash_log" "$latest_log" 2>/dev/null || true + else + rm -f "$crash_log" + fi + + rm -f "$log_tmp" "$filtered_tmp" "$tomb_tmp" +} + +stop_emulator() { + if [ -n "${EMULATOR_SERIAL:-}" ]; then + "$ADB_BIN" -s "$EMULATOR_SERIAL" emu kill >/dev/null 2>&1 || true + fi + if [ -n "${EMULATOR_PID:-}" ]; then + kill "$EMULATOR_PID" >/dev/null 2>&1 || true + wait "$EMULATOR_PID" 2>/dev/null || true + fi +} + +install_android_packages "$SDKMANAGER_BIN" + +ADB_BIN="$ANDROID_SDK_ROOT/platform-tools/adb" +if [ ! -x "$ADB_BIN" ]; then + if command -v adb >/dev/null 2>&1; then + ADB_BIN="$(command -v adb)" + else + ba_log "adb not found in Android SDK. Ensure platform-tools are installed." >&2 + exit 1 + fi +fi + +EMULATOR_BIN="$ANDROID_SDK_ROOT/emulator/emulator" +if [ ! -x "$EMULATOR_BIN" ]; then + if command -v emulator >/dev/null 2>&1; then + EMULATOR_BIN="$(command -v emulator)" + else + ba_log "Android emulator binary not found" >&2 + exit 1 + fi +fi + +AVD_NAME="cn1UiTestAvd" +SYSTEM_IMAGE="system-images;android-35;google_apis;x86_64" +if [ -n "${GITHUB_WORKSPACE:-}" ]; then + AVD_CACHE_ROOT="${AVD_CACHE_ROOT:-$GITHUB_WORKSPACE/.android-avd}" +else + AVD_CACHE_ROOT="${AVD_CACHE_ROOT:-${RUNNER_TEMP:-$HOME}/cn1-android-avd}" +fi +mkdir -p "$AVD_CACHE_ROOT" +AVD_HOME="$AVD_CACHE_ROOT" +ba_log "Using AVD home at $AVD_HOME" +create_avd "$AVDMANAGER_BIN" "$AVD_NAME" "$SYSTEM_IMAGE" "$AVD_HOME" + +ANDROID_AVD_HOME="$AVD_HOME" "$ADB_BIN" start-server >/dev/null + +mapfile -t EXISTING_EMULATORS < <("$ADB_BIN" devices | awk '/^emulator-/{print $1}') + +EMULATOR_PORT="${EMULATOR_PORT:-5560}" +if ! [[ "$EMULATOR_PORT" =~ ^[0-9]+$ ]]; then + EMULATOR_PORT=5560 +elif [ $((EMULATOR_PORT % 2)) -ne 0 ] || [ $EMULATOR_PORT -lt 5554 ] || [ $EMULATOR_PORT -gt 5584 ]; then + # emulator requires an even console port between 5554-5584; fall back if invalid + EMULATOR_PORT=5560 +fi +EMULATOR_SERIAL="emulator-$EMULATOR_PORT" + +EMULATOR_LOG="$GRADLE_PROJECT_DIR/emulator.log" +ba_log "Starting headless Android emulator $AVD_NAME on port $EMULATOR_PORT" +export ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL="${ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL:-120}" +export ANDROID_EMULATOR_KVM_DISABLE_CHECK=1 +export ANDROID_SDK_ROOT_NO_SETTINGS=1 +ANDROID_AVD_HOME="$AVD_HOME" "$EMULATOR_BIN" -avd "$AVD_NAME" -port "$EMULATOR_PORT" \ + -no-window -no-snapshot -no-snapshot-load -no-snapshot-save -wipe-data \ + -gpu auto -no-audio -no-boot-anim \ + -accel off -no-metrics -camera-back none -camera-front none -skip-adb-auth \ + -feature -Vulkan -feature -GLDMA -feature -GLDirectMem -no-passive-gps \ + -netdelay none -netspeed full -skin 720x1280 -memory 4096 -cores 4 \ + -partition-size 6144 -delay-adb >"$EMULATOR_LOG" 2>&1 & +EMULATOR_PID=$! +trap stop_emulator EXIT + +for _ in {1..60}; do + if "$ADB_BIN" -s "$EMULATOR_SERIAL" get-state >/dev/null 2>&1; then + break + fi + sleep 1 +done + +detect_emulator_serial() { + local deadline current_devices serial existing + deadline=$((SECONDS + 180)) + while [ $SECONDS -lt $deadline ]; do + mapfile -t current_devices < <("$ADB_BIN" devices | awk '/^emulator-/{print $1}') + for serial in "${current_devices[@]}"; do + for existing in "${EXISTING_EMULATORS[@]}"; do + if [ "$serial" = "$existing" ]; then + # already present before launch; ignore unless it matches requested serial + if [ "$serial" = "$EMULATOR_SERIAL" ]; then + EMULATOR_SERIAL="$serial" + return 0 + fi + serial="" + break + fi + done + if [ -n "$serial" ]; then + EMULATOR_SERIAL="$serial" + return 0 + fi + done + sleep 2 + done + return 1 +} + +if ! detect_emulator_serial; then + mapfile -t CURRENT_EMULATORS < <("$ADB_BIN" devices | awk '/^emulator-/{print $1}') + if [ -z "${EMULATOR_SERIAL:-}" ] && [ ${#CURRENT_EMULATORS[@]} -gt 0 ]; then + EMULATOR_SERIAL="${CURRENT_EMULATORS[0]}" + fi + if [ -z "${EMULATOR_SERIAL:-}" ] || ! printf '%s\n' "${CURRENT_EMULATORS[@]}" | grep -Fxq "$EMULATOR_SERIAL"; then + ba_log "Failed to detect emulator serial after launch" >&2 + if [ -f "$EMULATOR_LOG" ]; then + ba_log "Emulator log tail:" >&2 + tail -n 40 "$EMULATOR_LOG" | sed 's/^/[build-android-app] | /' >&2 + fi + stop_emulator + exit 1 + fi +fi +ba_log "Using emulator serial $EMULATOR_SERIAL" + +if ! wait_for_emulator "$EMULATOR_SERIAL"; then + stop_emulator + exit 1 +fi + +# Provision and wake the device before framework readiness checks +"$ADB_BIN" -s "$EMULATOR_SERIAL" shell svc power stayon true >/dev/null 2>&1 || true +"$ADB_BIN" -s "$EMULATOR_SERIAL" shell settings put global device_provisioned 1 >/dev/null 2>&1 || true +"$ADB_BIN" -s "$EMULATOR_SERIAL" shell settings put secure user_setup_complete 1 >/dev/null 2>&1 || true +"$ADB_BIN" -s "$EMULATOR_SERIAL" shell input keyevent 82 >/dev/null 2>&1 || true +"$ADB_BIN" -s "$EMULATOR_SERIAL" shell am start -a android.intent.action.MAIN -c android.intent.category.HOME >/dev/null 2>&1 || true + +system_server_pid="$("$ADB_BIN" -s "$EMULATOR_SERIAL" shell pidof system_server 2>/dev/null | tr -d '\r\n' || echo "")" +if [ -z "$system_server_pid" ]; then + ba_log "system_server not running after boot; restarting framework" + "$ADB_BIN" -s "$EMULATOR_SERIAL" shell stop >/dev/null 2>&1 || true + sleep 2 + "$ADB_BIN" -s "$EMULATOR_SERIAL" shell start >/dev/null 2>&1 || true +else + ba_log "system_server running with PID $system_server_pid" +fi + +if ! adb_wait_framework_ready "$EMULATOR_SERIAL"; then + dump_emulator_diagnostics + stop_emulator + exit 1 +fi + +POST_BOOT_GRACE="${EMULATOR_POST_BOOT_GRACE_SECONDS:-20}" +if ! [[ "$POST_BOOT_GRACE" =~ ^[0-9]+$ ]] || [ "$POST_BOOT_GRACE" -lt 0 ]; then + POST_BOOT_GRACE=20 +fi +if [ "$POST_BOOT_GRACE" -gt 0 ]; then + ba_log "Waiting ${POST_BOOT_GRACE}s for emulator system services to stabilize" + sleep "$POST_BOOT_GRACE" +fi + +if ! wait_for_package_service "$EMULATOR_SERIAL"; then + dump_emulator_diagnostics + stop_emulator + exit 1 +fi + +optimize_api35_after_boot "$EMULATOR_SERIAL" +"$ADB_BIN" -s "$EMULATOR_SERIAL" shell am get-current-user >/dev/null 2>&1 || true + +if ! wait_for_api_level "$EMULATOR_SERIAL"; then + dump_emulator_diagnostics + stop_emulator + exit 1 +fi + +"$ADB_BIN" -s "$EMULATOR_SERIAL" shell pm path android | sed 's/^/[build-android-app] pm path android: /' || true + +"$ADB_BIN" start-server >/dev/null 2>&1 || true +"$ADB_BIN" kill-server >/dev/null 2>&1 || true +"$ADB_BIN" start-server >/dev/null 2>&1 || true +"$ADB_BIN" -s "$EMULATOR_SERIAL" wait-for-device +export ANDROID_SERIAL="$EMULATOR_SERIAL" + +ASSEMBLE_TIMEOUT_SECONDS="${ASSEMBLE_TIMEOUT_SECONDS:-900}" +if ! [[ "$ASSEMBLE_TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] || [ "$ASSEMBLE_TIMEOUT_SECONDS" -le 0 ]; then + ASSEMBLE_TIMEOUT_SECONDS=900 +fi + +GRADLE_ASSEMBLE_CMD=( + "./gradlew" + "--no-daemon" + ":app:assembleDebug" + ":app:assembleDebugAndroidTest" + "-x" + "lint" + "-x" + "test" +) +if command -v timeout >/dev/null 2>&1; then + ba_log "Building app and androidTest APKs with external timeout of ${ASSEMBLE_TIMEOUT_SECONDS}s" + GRADLE_ASSEMBLE_CMD=("timeout" "$ASSEMBLE_TIMEOUT_SECONDS" "${GRADLE_ASSEMBLE_CMD[@]}") +else + ba_log "timeout command not found; running Gradle assemble tasks without external watchdog" +fi + +GRADLE_ASSEMBLE_LOG="$GRADLE_PROJECT_DIR/gradle-ui-assemble.log" +set +e ( cd "$GRADLE_PROJECT_DIR" - if command -v sdkmanager >/dev/null 2>&1; then - yes | sdkmanager --licenses >/dev/null 2>&1 || true - elif [ -x "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" ]; then - yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true - fi - ./gradlew --no-daemon assembleDebug + "${GRADLE_ASSEMBLE_CMD[@]}" | tee "$GRADLE_ASSEMBLE_LOG" + exit "${PIPESTATUS[0]}" ) +ASSEMBLE_EXIT_CODE=$? +set -e + +if [ -f "$GRADLE_ASSEMBLE_LOG" ]; then + cp "$GRADLE_ASSEMBLE_LOG" "$FINAL_ARTIFACT_DIR/ui-test-assemble.log" + ba_log "Gradle assemble log saved to $FINAL_ARTIFACT_DIR/ui-test-assemble.log" +fi + +if [ "$ASSEMBLE_EXIT_CODE" -ne 0 ]; then + ba_log "Gradle assemble tasks exited with status $ASSEMBLE_EXIT_CODE" + dump_manifest_merger_reports + stop_emulator + exit 1 +fi + +adb_install_file_path() { + local serial="$1" apk="$2" + local remote_tmp="/data/local/tmp/$(basename "$apk")" + local attempts="${ADB_INSTALL_ATTEMPTS:-3}" + local sleep_between="${ADB_INSTALL_RETRY_DELAY_SECONDS:-5}" + local attempt install_status apk_size + + if ! [[ "$attempts" =~ ^[0-9]+$ ]] || [ "$attempts" -le 0 ]; then + attempts=3 + fi + if ! [[ "$sleep_between" =~ ^[0-9]+$ ]]; then + sleep_between=5 + fi + + install_status=1 + + for attempt in $(seq 1 "$attempts"); do + if ! adb_wait_framework_ready "$serial"; then + ba_log "Android framework not ready before install attempt $attempt for $(basename "$apk")" + sleep "$sleep_between" + continue + fi + + "$ADB_BIN" -s "$serial" shell rm -f "$remote_tmp" >/dev/null 2>&1 || true + if ! "$ADB_BIN" -s "$serial" push "$apk" "$remote_tmp" >/dev/null 2>&1; then + ba_log "Failed to push $(basename "$apk") to $remote_tmp on attempt $attempt" >&2 + sleep "$sleep_between" + continue + fi + + if "$ADB_BIN" -s "$serial" shell pm install -r -t -d -g "$remote_tmp"; then + install_status=0 + else + apk_size=$(stat -c%s "$apk" 2>/dev/null || wc -c <"$apk") + if [ -n "$apk_size" ] && "$ADB_BIN" -s "$serial" shell "cat '$remote_tmp' | pm install -r -t -d -g -S $apk_size"; then + install_status=0 + fi + fi + + "$ADB_BIN" -s "$serial" shell rm -f "$remote_tmp" >/dev/null 2>&1 || true + + if [ "$install_status" -eq 0 ]; then + ba_log "Install of $(basename "$apk") succeeded on attempt $attempt" + break + fi + + ba_log "Install attempt $attempt for $(basename "$apk") failed; retrying after ${sleep_between}s" + sleep "$sleep_between" + done + + return $install_status +} + +ba_log "Inspecting Gradle application identifiers" + +APP_PROPERTIES_RAW=$(cd "$GRADLE_PROJECT_DIR" && ./gradlew -q :app:properties 2>/dev/null || true) +if [ -n "$APP_PROPERTIES_RAW" ]; then + set +o pipefail + MATCHED_PROPS=$(printf '%s\n' "$APP_PROPERTIES_RAW" | grep -E '^(applicationId|testApplicationId|namespace):' || true) + set -o pipefail + if [ -n "$MATCHED_PROPS" ]; then + printf '%s\n' "$MATCHED_PROPS" | sed 's/^/[build-android-app] props: /' + fi +fi + +APP_ID="$(printf '%s\n' "$APP_PROPERTIES_RAW" | awk -F': ' '/^applicationId:/{print $2; found=1} END{if(!found) print ""}' || true)" +NS_VALUE="$(printf '%s\n' "$APP_PROPERTIES_RAW" | awk -F': ' '/^namespace:/{print $2; found=1} END{if(!found) print ""}' || true)" + +if [ -z "$APP_ID" ]; then + ba_log "Gradle did not report applicationId; relying on APK metadata" +elif [ "$APP_ID" != "$PACKAGE_NAME" ]; then + ba_log "WARNING: Gradle applicationId '$APP_ID' differs from Codename One package '$PACKAGE_NAME'" >&2 +fi +if [ -z "$NS_VALUE" ]; then + ba_log "Gradle did not report namespace; relying on APK metadata" +elif [ "$NS_VALUE" != "$PACKAGE_NAME" ]; then + ba_log "WARNING: Gradle namespace '$NS_VALUE' differs from Codename One package '$PACKAGE_NAME'" >&2 +fi + +MERGED_MANIFEST="$APP_MODULE_DIR/build/intermediates/packaged_manifests/debug/AndroidManifest.xml" +if [ -f "$MERGED_MANIFEST" ]; then + if grep -Fq "android:name=\"$FQCN\"" "$MERGED_MANIFEST"; then + grep -Fn "android:name=\"$FQCN\"" "$MERGED_MANIFEST" | sed 's/^/[build-android-app] merged-manifest: /' + else + ba_log "ERROR: merged manifest missing $FQCN" + sed -n '1,160p' "$MERGED_MANIFEST" | sed 's/^/[build-android-app] merged: /' + stop_emulator + exit 1 + fi +else + ba_log "WARN: merged manifest not found at $MERGED_MANIFEST" +fi + +if [ -z "$APP_PROPERTIES_RAW" ]; then + ba_log "Warning: unable to query :app:properties" >&2 +fi + +APP_APK="$(find "$GRADLE_PROJECT_DIR/app/build/outputs/apk/debug" -maxdepth 1 -name '*-debug.apk' | head -n1 || true)" +TEST_APK="$(find "$GRADLE_PROJECT_DIR/app/build/outputs/apk/androidTest/debug" -maxdepth 1 -name '*-debug-androidTest.apk' | head -n1 || true)" + +if [ -z "$APP_APK" ] || [ ! -f "$APP_APK" ]; then + ba_log "App APK not found after identifier patch assemble" >&2 + stop_emulator + exit 1 +fi +if [ -z "$TEST_APK" ] || [ ! -f "$TEST_APK" ]; then + ba_log "androidTest APK not found after identifier patch assemble" >&2 + stop_emulator + exit 1 +fi + +AAPT_BIN="" +if [ -d "$ANDROID_SDK_ROOT/build-tools" ]; then + while IFS= read -r dir; do + if [ -x "$dir/aapt" ]; then + AAPT_BIN="$dir/aapt" + break + fi + done < <(find "$ANDROID_SDK_ROOT/build-tools" -maxdepth 1 -mindepth 1 -type d | sort -Vr) +fi + +RUNTIME_PACKAGE="$PACKAGE_NAME" +if [ -n "$AAPT_BIN" ] && [ -x "$AAPT_BIN" ]; then + APK_PACKAGE="$($AAPT_BIN dump badging "$APP_APK" 2>/dev/null | awk -F"'" '/^package: name=/{print $2; exit}')" + if [ -n "$APK_PACKAGE" ]; then + ba_log "aapt reported application package: $APK_PACKAGE" + if [ "$APK_PACKAGE" != "$PACKAGE_NAME" ]; then + ba_log "WARNING: APK package ($APK_PACKAGE) differs from Codename One package ($PACKAGE_NAME)" >&2 + fi + RUNTIME_PACKAGE="$APK_PACKAGE" + else + ba_log "WARN: Unable to extract application package using $AAPT_BIN" >&2 + fi +else + ba_log "WARN: aapt binary not found under $ANDROID_SDK_ROOT/build-tools; skipping APK package verification" >&2 +fi + +TEST_RUNTIME_PACKAGE="${RUNTIME_PACKAGE}.test" +RUNTIME_STUB_FQCN="${RUNTIME_PACKAGE}.${MAIN_NAME}Stub" + +ba_log "Preparing device for APK installation" +if ! adb_wait_framework_ready "$EMULATOR_SERIAL"; then + dump_emulator_diagnostics + stop_emulator + exit 1 +fi + +if "$ADB_BIN" -s "$EMULATOR_SERIAL" shell cmd package list sessions >/dev/null 2>&1; then + SESSION_IDS="$($ADB_BIN -s "$EMULATOR_SERIAL" shell cmd package list sessions 2>/dev/null | awk '{print $1}' | sed 's/sessionId=//g' || true)" + if [ -n "$SESSION_IDS" ]; then + while IFS= read -r sid; do + [ -z "$sid" ] && continue + "$ADB_BIN" -s "$EMULATOR_SERIAL" shell cmd package abort-session "$sid" >/dev/null 2>&1 || true + done <<<"$SESSION_IDS" + fi +fi + +"$ADB_BIN" -s "$EMULATOR_SERIAL" shell pm uninstall "$RUNTIME_PACKAGE" >/dev/null 2>&1 || true +"$ADB_BIN" -s "$EMULATOR_SERIAL" shell pm uninstall "$TEST_RUNTIME_PACKAGE" >/dev/null 2>&1 || true +"$ADB_BIN" -s "$EMULATOR_SERIAL" shell cmd package bg-dexopt-job >/dev/null 2>&1 || true +"$ADB_BIN" -s "$EMULATOR_SERIAL" shell rm -f /data/local/tmp/*.apk >/dev/null 2>&1 || true + +ba_log "Installing app APK: $APP_APK" +if ! adb_install_file_path "$EMULATOR_SERIAL" "$APP_APK"; then + ba_log "App APK install failed; restarting framework services and retrying" + "$ADB_BIN" -s "$EMULATOR_SERIAL" shell stop >/dev/null 2>&1 || true + sleep 2 + "$ADB_BIN" -s "$EMULATOR_SERIAL" shell start >/dev/null 2>&1 || true + if ! adb_install_file_path "$EMULATOR_SERIAL" "$APP_APK"; then + dump_emulator_diagnostics + stop_emulator + exit 1 + fi +fi + +ba_log "Installing androidTest APK: $TEST_APK" +if ! adb_install_file_path "$EMULATOR_SERIAL" "$TEST_APK"; then + ba_log "androidTest APK install failed; restarting framework services and retrying" + "$ADB_BIN" -s "$EMULATOR_SERIAL" shell stop >/dev/null 2>&1 || true + sleep 2 + "$ADB_BIN" -s "$EMULATOR_SERIAL" shell start >/dev/null 2>&1 || true + if ! adb_install_file_path "$EMULATOR_SERIAL" "$TEST_APK"; then + dump_emulator_diagnostics + stop_emulator + exit 1 + fi +fi + +if ! adb_wait_framework_ready "$EMULATOR_SERIAL"; then + dump_emulator_diagnostics + stop_emulator + exit 1 +fi + +if ! "$ADB_BIN" -s "$EMULATOR_SERIAL" shell pm list packages | grep -q "^package:${RUNTIME_PACKAGE//./\.}$"; then + ba_log "ERROR: Installed package $RUNTIME_PACKAGE not visible on $EMULATOR_SERIAL" + dump_emulator_diagnostics + stop_emulator + exit 1 +fi + +if ! "$ADB_BIN" -s "$EMULATOR_SERIAL" shell pm list packages | grep -q "^package:${TEST_RUNTIME_PACKAGE//./\.}$"; then + ba_log "ERROR: Installed test package $TEST_RUNTIME_PACKAGE not visible on $EMULATOR_SERIAL" + dump_emulator_diagnostics + stop_emulator + exit 1 +fi + +"$ADB_BIN" -s "$EMULATOR_SERIAL" shell pm list instrumentation | sed "s/^/[build-android-app] instrumentation: /" || true +"$ADB_BIN" -s "$EMULATOR_SERIAL" shell pm list packages | grep -E "${RUNTIME_PACKAGE//./\.}|${RUNTIME_PACKAGE//./\.}\\.test" | sed "s/^/[build-android-app] package: /" || true +"$ADB_BIN" -s "$EMULATOR_SERIAL" shell cmd package resolve-activity --brief "$RUNTIME_PACKAGE/$RUNTIME_STUB_FQCN" | sed "s/^/[build-android-app] resolve-stub (pre-test): /" || true + +APP_PACKAGE_PATH="$("$ADB_BIN" -s "$EMULATOR_SERIAL" shell pm path "$RUNTIME_PACKAGE" 2>/dev/null | tr -d '\r' || true)" +if [ -n "$APP_PACKAGE_PATH" ]; then + printf '%s\n' "$APP_PACKAGE_PATH" | sed 's/^/[build-android-app] app-apk: /' +else + ba_log "App package $RUNTIME_PACKAGE not yet reported by pm path on $EMULATOR_SERIAL" +fi + +TEST_PACKAGE_PATH="$("$ADB_BIN" -s "$EMULATOR_SERIAL" shell pm path "$TEST_RUNTIME_PACKAGE" 2>/dev/null | tr -d '\r' || true)" +if [ -n "$TEST_PACKAGE_PATH" ]; then + printf '%s\n' "$TEST_PACKAGE_PATH" | sed 's/^/[build-android-app] test-apk: /' +else + ba_log "Test package $TEST_RUNTIME_PACKAGE not yet reported by pm path on $EMULATOR_SERIAL" +fi + +log_instrumentation_state + +RUNNER="$( + "$ADB_BIN" -s "$EMULATOR_SERIAL" shell pm list instrumentation \ + | tr -d '\r' \ + | grep -F "(target=$RUNTIME_PACKAGE)" \ + | head -n1 \ + | sed -E 's/^instrumentation:([^ ]+).*/\1/' +)" +if [ -z "$RUNNER" ]; then + ba_log "No instrumentation runner found for $RUNTIME_PACKAGE" + "$ADB_BIN" -s "$EMULATOR_SERIAL" shell pm list instrumentation | sed 's/^/[build-android-app] instrumentation: /' + dump_emulator_diagnostics + stop_emulator + exit 1 +fi +ba_log "Using instrumentation runner: $RUNNER" + +ba_log "Inspecting launcher activities for $PACKAGE_NAME" +LAUNCH_RESOLVE_OUTPUT="$("$ADB_BIN" -s "$EMULATOR_SERIAL" shell cmd package resolve-activity --brief "$PACKAGE_NAME" 2>&1 || true)" +if [ -n "$LAUNCH_RESOLVE_OUTPUT" ]; then + printf '%s\n' "$LAUNCH_RESOLVE_OUTPUT" | sed 's/^/[build-android-app] resolve-launch: /' +fi +STUB_ACTIVITY_FQCN="$RUNTIME_PACKAGE/$RUNTIME_STUB_FQCN" +STUB_RESOLVE_OUTPUT="$( + "$ADB_BIN" -s "$EMULATOR_SERIAL" shell cmd package resolve-activity --brief "$STUB_ACTIVITY_FQCN" 2>&1 || true + )" +if [ -n "$STUB_RESOLVE_OUTPUT" ]; then + printf '%s\n' "$STUB_RESOLVE_OUTPUT" | sed 's/^/[build-android-app] resolve-stub: /' +else + ba_log "Unable to resolve stub activity $STUB_ACTIVITY_FQCN on device" +fi +if [[ "$STUB_RESOLVE_OUTPUT" == *"No activity found"* ]] || [ -z "$STUB_RESOLVE_OUTPUT" ]; then + ba_log "Stub activity $STUB_ACTIVITY_FQCN is not resolvable on the device" + dump_emulator_diagnostics + stop_emulator + exit 1 +fi + +"$ADB_BIN" -s "$EMULATOR_SERIAL" shell am force-stop "$RUNTIME_PACKAGE" >/dev/null 2>&1 || true +"$ADB_BIN" -s "$EMULATOR_SERIAL" shell am force-stop "$TEST_RUNTIME_PACKAGE" >/dev/null 2>&1 || true + +UI_TEST_TIMEOUT_SECONDS="${UI_TEST_TIMEOUT_SECONDS:-900}" +if ! [[ "$UI_TEST_TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] || [ "$UI_TEST_TIMEOUT_SECONDS" -le 0 ]; then + ba_log "Invalid UI_TEST_TIMEOUT_SECONDS=$UI_TEST_TIMEOUT_SECONDS provided; falling back to 900" + UI_TEST_TIMEOUT_SECONDS=900 +fi + +INSTRUMENT_EXIT_CODE=1 + +run_instrumentation() { + local tries=3 delay=15 attempt exit_code=1 + local args=(-w -r -e clearPackageData true -e log true -e class "${PACKAGE_NAME}.${MAIN_NAME}UiTest") + for attempt in $(seq 1 "$tries"); do + local cmd=("$ADB_BIN" "-s" "$EMULATOR_SERIAL" "shell" "am" "instrument" "${args[@]}" "$RUNNER") + if command -v timeout >/dev/null 2>&1; then + cmd=("timeout" "$UI_TEST_TIMEOUT_SECONDS" "${cmd[@]}") + ba_log "Instrumentation attempt $attempt/$tries with external timeout of ${UI_TEST_TIMEOUT_SECONDS}s" + else + ba_log "Instrumentation attempt $attempt/$tries without external watchdog" + fi + local attempt_log="$FINAL_ARTIFACT_DIR/ui-test-instrumentation-attempt-$attempt.log" + "$ADB_BIN" -s "$EMULATOR_SERIAL" logcat -c >/dev/null 2>&1 || true + set +e + "${cmd[@]}" | tee "$attempt_log" + exit_code=${PIPESTATUS[0]} + set -e + cp "$attempt_log" "$FINAL_ARTIFACT_DIR/ui-test-instrumentation.log" 2>/dev/null || true + if [ "$exit_code" -ne 0 ]; then + collect_instrumentation_crash "$attempt" + fi + if [ "$exit_code" -eq 0 ]; then + INSTRUMENT_EXIT_CODE=0 + return 0 + fi + if grep -q "INSTRUMENTATION_ABORTED: System has crashed." "$attempt_log" 2>/dev/null && [ "$attempt" -lt "$tries" ]; then + ba_log "System crashed during instrumentation attempt $attempt; retrying after ${delay}s" + sleep "$delay" + continue + fi + INSTRUMENT_EXIT_CODE=$exit_code + return $exit_code + done + INSTRUMENT_EXIT_CODE=$exit_code + return $exit_code +} + +if ! run_instrumentation; then + ba_log "Instrumentation command exited with status $INSTRUMENT_EXIT_CODE" + dump_emulator_diagnostics +fi + +copy_device_file() { + local src="$1" + local dest="$2" + if ! "$ADB_BIN" -s "$EMULATOR_SERIAL" shell run-as "$RUNTIME_PACKAGE" ls "$src" >/dev/null 2>&1; then + return 1 + fi + if "$ADB_BIN" -s "$EMULATOR_SERIAL" exec-out run-as "$RUNTIME_PACKAGE" cat "$src" >"$dest"; then + return 0 + fi + rm -f "$dest" + return 1 +} + +SCREENSHOT_STATUS=0 +ANDROID_SCREENSHOT="" +CODENAMEONE_SCREENSHOT="" +DEFAULT_SCREENSHOT="" + +SCREENSHOT_DIR_ON_DEVICE="files/ui-test-screenshots" +ANDROID_SCREENSHOT_NAME="${MAIN_NAME}-android-ui.png" +CODENAMEONE_SCREENSHOT_NAME="${MAIN_NAME}-codenameone-ui.png" + +ANDROID_SCREENSHOT_PATH_DEVICE="$SCREENSHOT_DIR_ON_DEVICE/$ANDROID_SCREENSHOT_NAME" +CODENAMEONE_SCREENSHOT_PATH_DEVICE="$SCREENSHOT_DIR_ON_DEVICE/$CODENAMEONE_SCREENSHOT_NAME" + +ANDROID_SCREENSHOT_DEST="$FINAL_ARTIFACT_DIR/$ANDROID_SCREENSHOT_NAME" +CODENAMEONE_SCREENSHOT_DEST="$FINAL_ARTIFACT_DIR/$CODENAMEONE_SCREENSHOT_NAME" + +if copy_device_file "$ANDROID_SCREENSHOT_PATH_DEVICE" "$ANDROID_SCREENSHOT_DEST"; then + ba_log "Android screenshot copied to $ANDROID_SCREENSHOT_DEST" + ANDROID_SCREENSHOT="$ANDROID_SCREENSHOT_DEST" + DEFAULT_SCREENSHOT="$ANDROID_SCREENSHOT_DEST" +else + ba_log "Android screenshot not found at $ANDROID_SCREENSHOT_PATH_DEVICE" >&2 + SCREENSHOT_STATUS=1 +fi + +if copy_device_file "$CODENAMEONE_SCREENSHOT_PATH_DEVICE" "$CODENAMEONE_SCREENSHOT_DEST"; then + ba_log "Codename One screenshot copied to $CODENAMEONE_SCREENSHOT_DEST" + CODENAMEONE_SCREENSHOT="$CODENAMEONE_SCREENSHOT_DEST" + if [ -z "$DEFAULT_SCREENSHOT" ]; then + DEFAULT_SCREENSHOT="$CODENAMEONE_SCREENSHOT_DEST" + fi +else + ba_log "Codename One screenshot not found at $CODENAMEONE_SCREENSHOT_PATH_DEVICE" >&2 + SCREENSHOT_STATUS=1 +fi + +if [ -f "$EMULATOR_LOG" ]; then + cp "$EMULATOR_LOG" "$FINAL_ARTIFACT_DIR/emulator.log" || true +fi + +if [ -n "${GITHUB_ENV:-}" ]; then + if [ -n "$DEFAULT_SCREENSHOT" ]; then + printf 'CN1_UI_TEST_SCREENSHOT=%s\n' "$DEFAULT_SCREENSHOT" >> "$GITHUB_ENV" + fi + if [ -n "$ANDROID_SCREENSHOT" ]; then + printf 'CN1_UI_TEST_ANDROID_SCREENSHOT=%s\n' "$ANDROID_SCREENSHOT" >> "$GITHUB_ENV" + fi + if [ -n "$CODENAMEONE_SCREENSHOT" ]; then + printf 'CN1_UI_TEST_CODENAMEONE_SCREENSHOT=%s\n' "$CODENAMEONE_SCREENSHOT" >> "$GITHUB_ENV" + fi +fi + export JAVA_HOME="$ORIGINAL_JAVA_HOME" +stop_emulator +trap - EXIT + +if [ "$INSTRUMENT_EXIT_CODE" -ne 0 ]; then + exit "$INSTRUMENT_EXIT_CODE" +fi + +if [ "$SCREENSHOT_STATUS" -ne 0 ]; then + exit 1 +fi + APK_PATH=$(find "$GRADLE_PROJECT_DIR" -path "*/outputs/apk/debug/*.apk" | head -n 1 || true) [ -n "$APK_PATH" ] || { ba_log "Gradle build completed but no APK was found" >&2; exit 1; } -ba_log "Successfully built Android APK at $APK_PATH" \ No newline at end of file +ba_log "Successfully built Android APK at $APK_PATH" diff --git a/scripts/build-android-port.sh b/scripts/build-android-port.sh index d0395724f9..c55bf7dd5f 100755 --- a/scripts/build-android-port.sh +++ b/scripts/build-android-port.sh @@ -27,8 +27,29 @@ load_environment() { ls -l "$ENV_FILE" | while IFS= read -r line; do log "$line"; done log "Workspace environment file contents" sed 's/^/[build-android-port] ENV: /' "$ENV_FILE" + + # Preserve system PATH before sourcing + SYSTEM_PATH_BACKUP="$PATH" + # shellcheck disable=SC1090 source "$ENV_FILE" + + # Restore system paths that may have been lost during sourcing + # Ensure common system binaries remain reachable + case ":$PATH:" in + *:/usr/bin:*) ;; + *) PATH="$PATH:/usr/bin" ;; + esac + case ":$PATH:" in + *:/bin:*) ;; + *) PATH="$PATH:/bin" ;; + esac + case ":$PATH:" in + *:/usr/local/bin:*) ;; + *) PATH="$PATH:/usr/local/bin" ;; + esac + + export PATH } check_java_home() { @@ -108,13 +129,12 @@ fi log "Loaded environment: JAVA_HOME=${JAVA_HOME:-} JAVA17_HOME=${JAVA17_HOME:-} MAVEN_HOME=${MAVEN_HOME:-}" -export PATH="$JAVA_HOME/bin:$MAVEN_HOME/bin:$PATH" "$JAVA_HOME/bin/java" -version "$JAVA17_HOME/bin/java" -version "$MAVEN_HOME/bin/mvn" -version run_maven() { - xvfb-run -a "$MAVEN_HOME/bin/mvn" "$@" + /usr/bin/xvfb-run -a "$MAVEN_HOME/bin/mvn" "$@" } BUILD_CLIENT="$HOME/.codenameone/CodeNameOneBuildClient.jar" @@ -124,4 +144,4 @@ if [ ! -f "$BUILD_CLIENT" ]; then fi fi -run_maven -q -f maven/pom.xml -pl android -am -Dmaven.javadoc.skip=true -Djava.awt.headless=true clean install "$@" +run_maven -q -f maven/pom.xml -pl android -am -Dmaven.javadoc.skip=true -Djava.awt.headless=true clean install "$@" \ No newline at end of file diff --git a/scripts/setup-workspace.sh b/scripts/setup-workspace.sh index fd59ef20d7..17bc9336e6 100755 --- a/scripts/setup-workspace.sh +++ b/scripts/setup-workspace.sh @@ -157,12 +157,12 @@ else fi log "Writing environment to $ENV_FILE" -cat > "$ENV_FILE" < "$ENV_FILE" < scenarioRule = + new ActivityScenarioRule<>(@MAIN_NAME@Stub.class); + + @Test + public void mainFormScreenshotContainsRenderedContent() throws Exception { + ActivityScenario<@MAIN_NAME@Stub> scenario = scenarioRule.getScenario(); + assertNotNull("ActivityScenario should be initialized", scenario); + + Form form = waitForMainForm(); + assertNotNull("Codename One main form should be available", form); + + Bitmap androidScreenshot = waitForAndroidScreenshot(scenario); + assertTrue("Android screenshot should show rendered content", hasRenderableContent(androidScreenshot)); + saveBitmap(androidScreenshot, "@MAIN_NAME@-android-ui.png"); + + Image codenameOneScreenshot = waitForCodenameOneScreenshot(); + assertTrue("Codename One screenshot should show rendered content", hasRenderableContent(codenameOneScreenshot)); + saveCodenameOneScreenshot(codenameOneScreenshot, "@MAIN_NAME@-codenameone-ui.png"); + } + + private Form waitForMainForm() throws Exception { + long deadline = SystemClock.uptimeMillis() + STARTUP_TIMEOUT_MS; + while (SystemClock.uptimeMillis() < deadline) { + if (Display.isInitialized()) { + Form current = callSeriallyAndWait(() -> Display.getInstance().getCurrent()); + if (current != null) { + return current; + } + } + SystemClock.sleep(50L); + } + throw new AssertionError("Timed out waiting for Codename One form to appear"); + } + + private Bitmap waitForAndroidScreenshot(ActivityScenario scenario) throws Exception { + AtomicReference lastCapture = new AtomicReference<>(); + long deadline = SystemClock.uptimeMillis() + RENDER_TIMEOUT_MS; + while (SystemClock.uptimeMillis() < deadline) { + scenario.onActivity(activity -> { + try { + Bitmap capture = captureAndroidScreenshot(activity); + if (capture != null) { + lastCapture.set(capture); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + Bitmap candidate = lastCapture.get(); + if (candidate != null && hasRenderableContent(candidate)) { + return candidate; + } + SystemClock.sleep(100L); + } + Bitmap fallback = lastCapture.get(); + if (fallback != null) { + return fallback; + } + throw new AssertionError("Timed out waiting for Android screenshot to contain rendered content"); + } + + private static Bitmap captureAndroidScreenshot(Activity activity) throws Exception { + Window window = activity.getWindow(); + View decor = window.getDecorView(); + ensureMeasured(decor); + + Bitmap viaPixelCopy = pixelCopy(window); + if (viaPixelCopy != null && hasRenderableContent(viaPixelCopy)) { + return viaPixelCopy; + } + + Bitmap viaTexture = captureTextureView(decor); + if (viaTexture != null && hasRenderableContent(viaTexture)) { + return viaTexture; + } + + return drawToBitmap(decor); + } + + private static void ensureMeasured(View view) { + if (view.getWidth() > 0 && view.getHeight() > 0) { + return; + } + int widthSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); + int heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); + view.measure(widthSpec, heightSpec); + view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight()); + } + + @Nullable + private static Bitmap pixelCopy(Window window) throws InterruptedException { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return null; + } + View decor = window.getDecorView(); + if (decor.getWidth() <= 0 || decor.getHeight() <= 0) { + return null; + } + Bitmap bitmap = Bitmap.createBitmap(decor.getWidth(), decor.getHeight(), Bitmap.Config.ARGB_8888); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference result = new AtomicReference<>(PixelCopy.ERROR_SOURCE_INVALID); + PixelCopy.request(window, bitmap, copyResult -> { + result.set(copyResult); + latch.countDown(); + }, new Handler(Looper.getMainLooper())); + if (latch.await(EDT_TIMEOUT_MS, TimeUnit.MILLISECONDS) && result.get() == PixelCopy.SUCCESS) { + return bitmap; + } + return null; + } + + @Nullable + private static Bitmap captureTextureView(View view) { + if (view instanceof TextureView) { + Bitmap bitmap = ((TextureView) view).getBitmap(); + if (bitmap != null) { + return bitmap; + } + } + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + Bitmap child = captureTextureView(group.getChildAt(i)); + if (child != null) { + return child; + } + } + } + return null; + } + + private static Bitmap drawToBitmap(View view) { + int width = Math.max(1, view.getWidth()); + int height = Math.max(1, view.getHeight()); + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + view.draw(canvas); + return bitmap; + } + + private Image waitForCodenameOneScreenshot() throws Exception { + Image lastCapture = null; + long deadline = SystemClock.uptimeMillis() + RENDER_TIMEOUT_MS; + while (SystemClock.uptimeMillis() < deadline) { + Image capture = captureCodenameOneScreenshot(); + if (capture != null) { + lastCapture = capture; + if (hasRenderableContent(capture)) { + return capture; + } + } + SystemClock.sleep(100L); + } + if (lastCapture != null) { + return lastCapture; + } + throw new AssertionError("Timed out waiting for Codename One screenshot to contain rendered content"); + } + + @Nullable + private Image captureCodenameOneScreenshot() throws Exception { + if (!Display.isInitialized()) { + return null; + } + return callSeriallyAndWait(() -> Display.getInstance().captureScreen()); + } + + private static boolean hasRenderableContent(Bitmap bitmap) { + if (bitmap == null || bitmap.getWidth() <= 1 || bitmap.getHeight() <= 1) { + return false; + } + int[] pixels = new int[bitmap.getWidth() * bitmap.getHeight()]; + bitmap.getPixels(pixels, 0, bitmap.getWidth(), 0, 0, bitmap.getWidth(), bitmap.getHeight()); + return hasRenderablePixels(pixels); + } + + private static boolean hasRenderableContent(Image image) throws Exception { + if (image == null || image.getWidth() <= 1 || image.getHeight() <= 1) { + return false; + } + int[] pixels = callSeriallyAndWait(image::getRGB); + return hasRenderablePixels(pixels); + } + + private static boolean hasRenderablePixels(int[] pixels) { + if (pixels == null || pixels.length == 0) { + return false; + } + + final int minVisiblePixels = 32; + final int minDifferingPixels = 8; + final int minLumaSpread = 12; + + int reference = -1; + int differing = 0; + int visible = 0; + int minLuma = 255; + int maxLuma = 0; + + for (int argb : pixels) { + int alpha = (argb >>> 24) & 0xFF; + if (alpha < 16) { + continue; + } + + visible++; + int rgb = argb & 0x00FFFFFF; + if (reference == -1) { + reference = rgb; + } else if (rgb != reference) { + differing++; + } + + int r = (rgb >>> 16) & 0xFF; + int g = (rgb >>> 8) & 0xFF; + int b = rgb & 0xFF; + int luma = (299 * r + 587 * g + 114 * b) / 1000; + if (luma < minLuma) { + minLuma = luma; + } + if (luma > maxLuma) { + maxLuma = luma; + } + + if (visible >= minVisiblePixels && differing >= minDifferingPixels) { + return true; + } + } + + return visible >= minVisiblePixels && (differing >= minDifferingPixels || (maxLuma - minLuma) >= minLumaSpread); + } + + private static File saveBitmap(Bitmap bitmap, String fileName) throws IOException { + File dir = resolveArtifactDirectory(); + File destination = new File(dir, fileName); + try (FileOutputStream out = new FileOutputStream(destination)) { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); + } + return destination; + } + + private static File saveCodenameOneScreenshot(Image screenshot, String fileName) throws Exception { + File dir = resolveArtifactDirectory(); + File destination = new File(dir, fileName); + ImageIO imageIO = callSeriallyAndWait(() -> Display.getInstance().getImageIO()); + assertNotNull("Codename One ImageIO should be available", imageIO); + try (FileOutputStream out = new FileOutputStream(destination)) { + final FileOutputStream target = out; + callSeriallyAndWait(() -> { + imageIO.save(screenshot, target, ImageIO.FORMAT_PNG, 1.0f); + return null; + }); + } + return destination; + } + + private static File resolveArtifactDirectory() throws IOException { + File filesDir = InstrumentationRegistry.getInstrumentation() + .getTargetContext() + .getFilesDir(); + File output = new File(filesDir, "ui-test-screenshots"); + if (!output.exists() && !output.mkdirs()) { + throw new IOException("Failed to create " + output.getAbsolutePath()); + } + return output; + } + + private static T callSeriallyAndWait(Callable task) throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference result = new AtomicReference<>(); + AtomicReference error = new AtomicReference<>(); + Display.getInstance().callSerially(() -> { + try { + result.set(task.call()); + } catch (Throwable t) { + error.set(t); + } finally { + latch.countDown(); + } + }); + if (!latch.await(EDT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + throw new AssertionError("Timed out waiting for Codename One EDT"); + } + if (error.get() != null) { + Throwable throwable = error.get(); + if (throwable instanceof Exception) { + throw (Exception) throwable; + } + throw new RuntimeException(throwable); + } + return result.get(); + } +} diff --git a/scripts/update_android_ui_test_gradle.py b/scripts/update_android_ui_test_gradle.py new file mode 100755 index 0000000000..47d1564e51 --- /dev/null +++ b/scripts/update_android_ui_test_gradle.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +"""Apply minimal instrumentation test configuration to the generated Gradle project.""" +from __future__ import annotations + +import argparse +import pathlib +import re +import sys +from typing import Iterable, Optional + +COMPILE_SDK = 35 +MIN_SDK = 19 +TARGET_SDK = 35 +VERSION_CODE = 100 +VERSION_NAME = "1.0" + +ANDROID_TEST_DEPENDENCIES = ( + "androidx.test:core:1.5.0", + "androidx.test.ext:junit:1.1.5", + "androidx.test:rules:1.5.0", + "androidx.test:runner:1.5.2", + "androidx.test.espresso:espresso-core:3.5.1", + "androidx.test.uiautomator:uiautomator:2.2.0", +) + +# Snippet injected into the android { } block to keep instrumentation runs stable. +TEST_OPTIONS_SNIPPET: str = """ + testOptions { + animationsDisabled = true + } +""" + + +class GradleFile: + def __init__(self, content: str) -> None: + self.content = content + self.configuration_used: str | None = None + self.added_dependencies: list[str] = [] + self._package_name: str | None = None + + def set_package_name(self, package_name: Optional[str]) -> None: + self._package_name = package_name + + def _iter_blocks(self) -> Iterable[tuple[str, int, int, list[str]]]: + content = self.content + length = len(content) + stack: list[tuple[str, int]] = [] + i = 0 + while i < length: + if content.startswith("//", i): + end = content.find("\n", i) + if end == -1: + break + i = end + 1 + continue + if content.startswith("/*", i): + end = content.find("*/", i + 2) + if end == -1: + break + i = end + 2 + continue + char = content[i] + if char in ('"', "'"): + quote = char + i += 1 + while i < length: + if content[i] == "\\": + i += 2 + continue + if content[i] == quote: + i += 1 + break + i += 1 + continue + if char == '}': + if stack: + name, start = stack.pop() + parents = [entry[0] for entry in stack] + yield name, start, i + 1, parents + i += 1 + continue + match = re.match(r'[A-Za-z_][A-Za-z0-9_\.]*', content[i:]) + if match: + name = match.group(0) + j = i + len(name) + while j < length and content[j].isspace(): + j += 1 + if j < length and content[j] == '{': + stack.append((name, i)) + i = j + 1 + continue + i += 1 + + def _find_block(self, name: str, *, parent: str | None = None) -> tuple[int, int] | None: + for block_name, start, end, parents in self._iter_blocks(): + if block_name != name: + continue + if parent is None: + if not parents: + return start, end + else: + if parents and parents[-1] == parent: + return start, end + return None + + def ensure_namespace(self) -> None: + if not self._package_name: + return + android_block = self._find_block("android") + if not android_block: + raise SystemExit("Unable to locate android block in Gradle file") + block_text = self.content[android_block[0]:android_block[1]] + pattern = re.compile(r"^(\s*)namespace\s+['\"]([^'\"]+)['\"]\s*$", re.MULTILINE) + + def repl(match: re.Match[str]) -> str: + indent = match.group(1) + return f"{indent}namespace \"{self._package_name}\"" + + if pattern.search(block_text): + block_text = pattern.sub(repl, block_text, count=1) + else: + insert = block_text.find('\n', block_text.find('android')) + if insert == -1: + brace = block_text.find('{') + if brace == -1: + raise SystemExit("Malformed android block") + insert = brace + 1 + else: + insert += 1 + namespace_line = f" namespace \"{self._package_name}\"\n" + block_text = block_text[:insert] + namespace_line + block_text[insert:] + self.content = self.content[:android_block[0]] + block_text + self.content[android_block[1]:] + + def ensure_compile_sdk(self) -> None: + pattern = re.compile(r"^(\s*)compileSdk(?:Version)?\s+\d+\s*$", re.MULTILINE) + + def repl(match: re.Match[str]) -> str: + indent = match.group(1) + return f"{indent}compileSdk {COMPILE_SDK}" + + if pattern.search(self.content): + self.content = pattern.sub(repl, self.content, count=1) + return + android_block = self._find_block("android") + if not android_block: + raise SystemExit("Unable to locate android block in Gradle file") + insert = self.content.find('\n', android_block[0]) + 1 + if insert <= 0: + insert = android_block[0] + len("android {") + line = f" compileSdk {COMPILE_SDK}\n" + self.content = self.content[:insert] + line + self.content[insert:] + + @staticmethod + def _ensure_block_entry(block_text: str, key: str, replacement: str) -> str: + pattern = re.compile(rf"^(\s*){key}\b.*$", re.MULTILINE) + if pattern.search(block_text): + def repl(match: re.Match[str]) -> str: + indent = match.group(1) + return f"{indent}{replacement}" + + return pattern.sub(repl, block_text, count=1) + indent_match = re.search(r"\{\s*\n(\s*)", block_text) + indent = indent_match.group(1) if indent_match else " " + brace_index = block_text.find('{') + if brace_index == -1: + return block_text + insert_pos = block_text.find('\n', brace_index) + if insert_pos == -1: + block_text = block_text + '\n' + insert_pos = len(block_text) - 1 + insert_pos += 1 + insertion = f"{indent}{replacement}\n" + return block_text[:insert_pos] + insertion + block_text[insert_pos:] + + def ensure_default_config(self) -> None: + replacements: list[tuple[str, str]] = [ + ("minSdk", f"minSdk {MIN_SDK}"), + ("targetSdk", f"targetSdk {TARGET_SDK}"), + ("versionCode", f"versionCode {VERSION_CODE}"), + ("versionName", f"versionName \"{VERSION_NAME}\""), + ( + "testInstrumentationRunner", + "testInstrumentationRunner \"androidx.test.runner.AndroidJUnitRunner\"", + ), + ("multiDexEnabled", "multiDexEnabled true"), + ] + if self._package_name: + replacements.insert(0, ("applicationId", f"applicationId \"{self._package_name}\"")) + block = self._find_block("defaultConfig", parent="android") + if not block: + lines = [" defaultConfig {"] + for _, line in replacements: + lines.append(f" {line}") + lines.append(" }\n") + snippet = "\n".join(lines) + android_block = self._find_block("android") + if not android_block: + raise SystemExit("Unable to locate android block in Gradle file") + insert = self.content.find('\n', android_block[0]) + 1 + if insert <= 0: + insert = android_block[0] + len("android {") + self.content = self.content[:insert] + snippet + self.content[insert:] + return + block_text = self.content[block[0]:block[1]] + for key, replacement in replacements: + block_text = self._ensure_block_entry(block_text, key, replacement) + self.content = self.content[:block[0]] + block_text + self.content[block[1]:] + + def ensure_test_options(self) -> None: + if "animationsDisabled" in self.content: + return + test_block = self._find_block("testOptions", parent="android") + if test_block: + insert = self.content.find('\n', test_block[0]) + 1 + body = " animationsDisabled = true\n" + self.content = ( + self.content[:insert] + + body + + self.content[insert:test_block[1]] + + self.content[test_block[1]:] + ) + return + android_block = self._find_block("android") + if not android_block: + raise SystemExit("Unable to locate android block in Gradle file") + insert = self.content.find('\n', android_block[0]) + 1 + if insert <= 0: + insert = android_block[0] + len("android {") + self.content = self.content[:insert] + TEST_OPTIONS_SNIPPET + self.content[insert:] + + def remove_dex_options(self) -> None: + self.content = re.sub(r"\s*dexOptions\s*\{[^{}]*\}\s*", "\n", self.content) + + def convert_lint_options(self) -> None: + block = self._find_block("lintOptions", parent="android") + if not block: + return + block_text = self.content[block[0]:block[1]] + start = block_text.find('{') + end = block_text.rfind('}') + if start == -1 or end == -1 or end <= start: + self.content = ( + self.content[:block[0]] + + " lint {\n }\n" + + self.content[block[1]:] + ) + return + body = block_text[start + 1 : end] + lines = [] + for line in body.splitlines(): + stripped = line.strip() + if not stripped: + lines.append("") + else: + lines.append(f" {stripped}") + joined = "\n".join(lines) + if joined: + joined = joined + "\n" + replacement = f" lint {{\n{joined} }}\n" + self.content = self.content[:block[0]] + replacement + self.content[block[1]:] + + def remove_jcenter(self) -> None: + self.content = re.sub(r"^\s*jcenter\(\)\s*$\n?", "", self.content, flags=re.MULTILINE) + + def ensure_dependencies(self) -> None: + block = self._find_dependencies_block() + if not block: + self._append_dependencies_block() + block = self._find_dependencies_block() + if not block: + raise SystemExit("Unable to locate dependencies block in Gradle file after insertion") + existing_block = self.content[block[0]:block[1]] + configuration = self._select_configuration(existing_block, self.content) + self.configuration_used = configuration + insertion_point = block[1] - 1 + for coordinate in ANDROID_TEST_DEPENDENCIES: + if self._has_dependency(existing_block, coordinate): + continue + combined = f" {configuration} \"{coordinate}\"\n" + self.content = ( + self.content[:insertion_point] + + combined + + self.content[insertion_point:] + ) + insertion_point += len(combined) + existing_block += combined + self.added_dependencies.append(coordinate) + + @staticmethod + def _has_dependency(block: str, coordinate: str) -> bool: + escaped = re.escape(coordinate) + return bool( + re.search(rf"androidTestImplementation\s+['\"]{escaped}['\"]", block) + or re.search(rf"androidTestCompile\s+['\"]{escaped}['\"]", block) + ) + + def _find_dependencies_block(self) -> tuple[int, int] | None: + preferred: tuple[int, int] | None = None + fallback: tuple[int, int] | None = None + for name, start, end, parents in self._iter_blocks(): + if name != "dependencies": + continue + if parents and parents[-1] in {"buildscript", "pluginManagement"}: + continue + block_content = self.content[start:end] + if re.search( + r"^\s*(implementation|api|compile|androidTestImplementation|androidTestCompile)\b", + block_content, + re.MULTILINE, + ): + preferred = (start, end) + break + if "classpath" in block_content and not re.search( + r"^\s*(implementation|api|compile)\b", block_content, re.MULTILINE + ): + continue + if fallback is None: + fallback = (start, end) + return preferred or fallback + + def _append_dependencies_block(self) -> None: + if not self.content.endswith("\n"): + self.content += "\n" + self.content += "\ndependencies {\n}\n" + + @staticmethod + def _select_configuration(block: str, content: str) -> str: + if re.search(r"^\s*androidTestImplementation\b", block, re.MULTILINE): + return "androidTestImplementation" + if re.search(r"^\s*androidTestCompile\b", block, re.MULTILINE): + return "androidTestCompile" + if re.search(r"^\s*androidTestImplementation\b", content, re.MULTILINE): + return "androidTestImplementation" + if re.search(r"^\s*androidTestCompile\b", content, re.MULTILINE): + return "androidTestCompile" + if re.search(r"^\s*implementation\b", block, re.MULTILINE) or re.search( + r"^\s*implementation\b", content, re.MULTILINE + ): + return "androidTestImplementation" + if re.search(r"^\s*compile\b", block, re.MULTILINE) or re.search( + r"^\s*compile\b", content, re.MULTILINE + ): + return "androidTestCompile" + return "androidTestImplementation" + + def apply(self) -> None: + self.ensure_namespace() + self.ensure_compile_sdk() + self.ensure_default_config() + self.ensure_test_options() + self.remove_dex_options() + self.convert_lint_options() + self.remove_jcenter() + self.ensure_dependencies() + + def summary(self) -> str: + configuration = self.configuration_used or "" + if self.added_dependencies: + deps = ", ".join(self.added_dependencies) + else: + deps = "none (already present)" + return ( + "Instrumentation dependency configuration: " + f"{configuration}; added dependencies: {deps}" + ) + + +def process(path: pathlib.Path, package_name: Optional[str]) -> None: + content = path.read_text(encoding="utf-8") + if "android {" not in content: + raise SystemExit( + "Selected Gradle file doesn't contain an android { } block. Check module path." + ) + editor = GradleFile(content) + editor.set_package_name(package_name) + editor.apply() + path.write_text(editor.content, encoding="utf-8") + print(editor.summary()) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("gradle_file", type=pathlib.Path) + parser.add_argument("--package-name", dest="package_name", default=None) + args = parser.parse_args(argv) + + if not args.gradle_file.is_file(): + parser.error(f"Gradle file not found: {args.gradle_file}") + + process(args.gradle_file, args.package_name) + return 0 + + +if __name__ == "__main__": + sys.exit(main())