diff --git a/.github/workflows/scripts-android.yml b/.github/workflows/scripts-android.yml index b1ba7def43..84a9bd1e10 100644 --- a/.github/workflows/scripts-android.yml +++ b/.github/workflows/scripts-android.yml @@ -23,3 +23,10 @@ jobs: run: ./scripts/build-android-port.sh -q -DskipTests - name: Build Hello Codename One Android app run: ./scripts/build-android-app.sh -q -DskipTests + - 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 diff --git a/.gitignore b/.gitignore index 0d12a7309f..f88f65279d 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ **/.idea/* **/build/* **/dist/* +build-artifacts/ *.zip CodenameOneDesigner/src/version.properties /Ports/iOSPort/build/ diff --git a/scripts/build-android-app.sh b/scripts/build-android-app.sh index f424546247..020e0eec31 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" @@ -265,21 +275,1348 @@ 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) + if "tools:node=" in body: + body = re.sub(r'tools:node="[^"]*"', 'tools:node="replace"', body, count=1) + else: + close = body.find('>') + if close != -1: + body = body[:close] + ' tools:node="replace"' + 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 = "compileSdkVersion 35" + if match: + start, end = match.span() + current = match.group(0) + if current != desired: + source = source[:start] + desired + source[end:] + messages.append("Updated compileSdkVersion 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 compileSdkVersion") + insert = android_match.end() + source = source[:insert] + f"\n {desired}" + source[insert:] + messages.append("Inserted compileSdkVersion 35") + return source + +def ensure_default_config_value(source: str, key: str, value: str) -> str: + pattern = re.compile(rf"{key}\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 = f" {key} {value}" + if match: + if match.group(1) != value: + 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, "minSdkVersion", "19") +text = ensure_default_config_value(text, "targetSdkVersion", "35") +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" "$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 +} + +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 ini_file="$avd_dir/$name.ini" + local image_dir="$avd_dir/$name.avd" + if [ -f "$ini_file" ] && [ -d "$image_dir" ]; then + if grep -F -q "$image" "$ini_file" 2>/dev/null; then + ba_log "Reusing existing Android Virtual Device $name" + configure_avd "$avd_dir" "$name" + return + fi + ba_log "Existing Android Virtual Device $name uses a different system image; recreating" + rm -f "$ini_file" + rm -rf "$image_dir" + fi + if ! ANDROID_AVD_HOME="$avd_dir" "$manager" create avd -n "$name" -k "$image" --device "2.7in QVGA" --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" +} + +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"]=3072 + ["disk.dataPartition.size"]=8192M + ["hw.bluetooth"]=no + ["hw.camera.back"]=none + ["hw.camera.front"]=none + ["hw.audioInput"]=no + ["hw.audioOutput"]=no + ) + 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:-900}" + if ! [[ "$boot_timeout" =~ ^[0-9]+$ ]] || [ "$boot_timeout" -le 0 ]; then + ba_log "Invalid EMULATOR_BOOT_TIMEOUT_SECONDS=$boot_timeout provided; falling back to 900" + boot_timeout=900 + fi + local poll_interval="${EMULATOR_BOOT_POLL_INTERVAL_SECONDS:-5}" + if ! [[ "$poll_interval" =~ ^[0-9]+$ ]] || [ "$poll_interval" -le 0 ]; then + poll_interval=5 + fi + local status_log_interval="${EMULATOR_BOOT_STATUS_LOG_INTERVAL_SECONDS:-30}" + if ! [[ "$status_log_interval" =~ ^[0-9]+$ ]] || [ "$status_log_interval" -le 0 ]; then + status_log_interval=30 + fi + + local deadline=$((SECONDS + boot_timeout)) + local last_log=$SECONDS + local boot_completed="0" + local dev_boot_completed="0" + local bootanim="" + local bootanim_exit="" + local device_state="" + local boot_ready=0 + + while [ $SECONDS -lt $deadline ]; do + device_state="$($ADB_BIN -s "$serial" get-state 2>/dev/null | tr -d '\r')" + if [ "$device_state" != "device" ]; then + if [ $((SECONDS - last_log)) -ge $status_log_interval ]; then + ba_log "Waiting for emulator $serial to become ready (state=$device_state)" + last_log=$SECONDS + fi + sleep "$poll_interval" + continue + fi + + boot_completed="$($ADB_BIN -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" + dev_boot_completed="$($ADB_BIN -s "$serial" shell getprop dev.bootcomplete 2>/dev/null | tr -d '\r')" + bootanim="$($ADB_BIN -s "$serial" shell getprop init.svc.bootanim 2>/dev/null | tr -d '\r')" + bootanim_exit="$($ADB_BIN -s "$serial" shell getprop service.bootanim.exit 2>/dev/null | tr -d '\r')" + + if { [ "$boot_completed" = "1" ] || [ "$boot_completed" = "true" ]; } \ + && { [ -z "$dev_boot_completed" ] || [ "$dev_boot_completed" = "1" ] || [ "$dev_boot_completed" = "true" ]; }; then + boot_ready=1 + break + fi + + if [ "$bootanim" = "stopped" ] || [ "$bootanim_exit" = "1" ]; then + boot_ready=2 + break + fi + + if [ $((SECONDS - last_log)) -ge $status_log_interval ]; then + ba_log "Waiting for emulator $serial to boot (sys.boot_completed=${boot_completed:-} dev.bootcomplete=${dev_boot_completed:-} bootanim=${bootanim:-} bootanim_exit=${bootanim_exit:-})" + last_log=$SECONDS + fi + sleep "$poll_interval" + done + + if [ $boot_ready -eq 0 ]; then + ba_log "Emulator $serial failed to boot within ${boot_timeout}s (sys.boot_completed=${boot_completed:-} dev.bootcomplete=${dev_boot_completed:-} bootanim=${bootanim:-} bootanim_exit=${bootanim_exit:-} state=${device_state:-})" >&2 + return 1 + elif [ $boot_ready -eq 2 ]; then + ba_log "Emulator $serial reported boot animation stopped; proceeding without bootcomplete properties" + fi + + "$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:-${PACKAGE_SERVICE_TIMEOUT:-600}}" + local per_try="${PACKAGE_SERVICE_PER_TRY_TIMEOUT_SECONDS:-${PACKAGE_SERVICE_PER_TRY_TIMEOUT:-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 + + while [ $SECONDS -lt $deadline ]; do + local boot_ok ce_ok + boot_ok="$($ADB_BIN -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" + ce_ok="$($ADB_BIN -s "$serial" shell getprop sys.user.0.ce_available 2>/dev/null | tr -d '\r')" + + if timeout "$per_try" "$ADB_BIN" -s "$serial" shell cmd package path android >/dev/null 2>&1 \ + || timeout "$per_try" "$ADB_BIN" -s "$serial" shell pm path android >/dev/null 2>&1 \ + || timeout "$per_try" "$ADB_BIN" -s "$serial" shell cmd package list packages >/dev/null 2>&1 \ + || timeout "$per_try" "$ADB_BIN" -s "$serial" shell pm list packages >/dev/null 2>&1 \ + || timeout "$per_try" "$ADB_BIN" -s "$serial" shell dumpsys package >/dev/null 2>&1; then + return 0 + fi + + if [ $((SECONDS - last_log)) -ge 10 ]; then + ba_log "Waiting for package manager service on $serial (boot_ok=${boot_ok:-?} ce_ok=${ce_ok:-?})" + last_log=$SECONDS + fi + sleep 2 + done + + ba_log "Package manager service not ready on $serial after ${timeout}s" >&2 + return 1 +} + +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 +} + +ensure_framework_ready() { + local serial="$1" + "$ADB_BIN" -s "$serial" wait-for-device >/dev/null 2>&1 || return 1 + + local total_timeout="${FRAMEWORK_READY_TIMEOUT_SECONDS:-300}" + local per_try="${FRAMEWORK_READY_PER_TRY_TIMEOUT_SECONDS:-5}" + if ! [[ "$total_timeout" =~ ^[0-9]+$ ]] || [ "$total_timeout" -le 0 ]; then + total_timeout=300 + fi + if ! [[ "$per_try" =~ ^[0-9]+$ ]] || [ "$per_try" -le 0 ]; then + per_try=5 + fi + + local deadline=$((SECONDS + total_timeout)) + local last_log=$SECONDS + + while [ $SECONDS -lt $deadline ]; do + 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 pm list packages >/dev/null 2>&1 \ + || run_with_timeout "$per_try" "$ADB_BIN" -s "$serial" shell dumpsys package >/dev/null 2>&1; then + return 0 + fi + + if [ $((SECONDS - last_log)) -ge 10 ]; then + local boot_ok="$($ADB_BIN -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" + local ce_ok="$($ADB_BIN -s "$serial" shell getprop sys.user.0.ce_available 2>/dev/null | tr -d '\r')" + ba_log "Waiting for framework readiness on $serial (boot_ok=${boot_ok:-?} ce_ok=${ce_ok:-?})" + last_log=$SECONDS + fi + sleep 2 + done + + ba_log "Framework appears stalled on $serial; restarting services" >&2 + "$ADB_BIN" -s "$serial" shell stop >/dev/null 2>&1 || true + sleep 2 + "$ADB_BIN" -s "$serial" shell start >/dev/null 2>&1 || true + + local restart_deadline=$((SECONDS + 240)) + while [ $SECONDS -lt $restart_deadline ]; do + 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 pm list packages >/dev/null 2>&1 \ + || run_with_timeout "$per_try" "$ADB_BIN" -s "$serial" shell dumpsys package >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + ba_log "ERROR: Package Manager not available on $serial after restart" >&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" +AVD_CACHE_ROOT="${AVD_CACHE_ROOT:-${RUNNER_TEMP:-$HOME}/cn1-android-avd}" +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" +EMULATOR_ADDITIONAL_ARGS=() +WIPE_SENTINEL="$AVD_HOME/.${AVD_NAME}.wiped" +if [ ! -f "$WIPE_SENTINEL" ]; then + EMULATOR_ADDITIONAL_ARGS+=(-wipe-data) +fi +ANDROID_AVD_HOME="$AVD_HOME" "$EMULATOR_BIN" -avd "$AVD_NAME" -port "$EMULATOR_PORT" \ + -no-window -no-snapshot -gpu swiftshader_indirect -no-audio -no-boot-anim \ + -accel off -no-accel -camera-back none -camera-front none -skip-adb-auth \ + -feature -Vulkan -netfast -memory 3072 "${EMULATOR_ADDITIONAL_ARGS[@]}" >"$EMULATOR_LOG" 2>&1 & +EMULATOR_PID=$! +trap stop_emulator EXIT + +sleep 5 + +if [ ! -f "$WIPE_SENTINEL" ]; then + touch "$WIPE_SENTINEL" 2>/dev/null || true +fi + +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 + +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 + +"$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 wm dismiss-keyguard >/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" 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")" + + if ! ensure_framework_ready "$serial"; then + return 1 + 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" >&2 + return 1 + fi + + if "$ADB_BIN" -s "$serial" shell pm install -r -t -g "$remote_tmp"; then + "$ADB_BIN" -s "$serial" shell rm -f "$remote_tmp" >/dev/null 2>&1 || true + return 0 + fi + + local size + size=$(stat -c%s "$apk" 2>/dev/null || wc -c <"$apk") + if [ -n "$size" ]; then + ba_log "Falling back to size-piped install for $(basename "$apk")" + if "$ADB_BIN" -s "$serial" shell "cat '$remote_tmp' | pm install -r -t -g -S $size"; then + "$ADB_BIN" -s "$serial" shell rm -f "$remote_tmp" >/dev/null 2>&1 || true + return 0 + fi + fi + + "$ADB_BIN" -s "$serial" shell rm -f "$remote_tmp" >/dev/null 2>&1 || true + return 1 +} + +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" ] || [ "$APP_ID" != "$PACKAGE_NAME" ] || [ -z "$NS_VALUE" ] || [ "$NS_VALUE" != "$PACKAGE_NAME" ]; then + ba_log "applicationId/namespace mismatch (applicationId='${APP_ID:-}' namespace='${NS_VALUE:-}'), patching" + if ! GRADLE_PACKAGE_LOG=$(ensure_gradle_package_config); then + ba_log "Failed to align namespace/applicationId with Codename One package" >&2 + stop_emulator + 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 + 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" ] || [ "$APP_ID" != "$PACKAGE_NAME" ] || [ -z "$NS_VALUE" ] || [ "$NS_VALUE" != "$PACKAGE_NAME" ]; then + ba_log "WARNING: applicationId/namespace remain misaligned (applicationId='${APP_ID:-}' namespace='${NS_VALUE:-}'); continuing with APK inspection" >&2 + fi +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 ! ensure_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 + dump_emulator_diagnostics + stop_emulator + exit 1 +fi + +ba_log "Installing androidTest APK: $TEST_APK" +if ! adb_install_file_path "$EMULATOR_SERIAL" "$TEST_APK"; then + dump_emulator_diagnostics + stop_emulator + exit 1 +fi + +if ! ensure_framework_ready "$EMULATOR_SERIAL"; then + 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/templates/HelloCodenameOneUiTest.java.tmpl b/scripts/templates/HelloCodenameOneUiTest.java.tmpl new file mode 100644 index 0000000000..032eb0ed62 --- /dev/null +++ b/scripts/templates/HelloCodenameOneUiTest.java.tmpl @@ -0,0 +1,315 @@ +package @PACKAGE@; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.view.PixelCopy; +import android.view.TextureView; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; + +import androidx.annotation.Nullable; +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Image; +import com.codename1.ui.util.ImageIO; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +@RunWith(AndroidJUnit4.class) +public class @MAIN_NAME@UiTest { + + private static final long STARTUP_TIMEOUT_MS = 30_000L; + private static final long RENDER_TIMEOUT_MS = 15_000L; + private static final long EDT_TIMEOUT_MS = 10_000L; + + @Rule + public final ActivityScenarioRule<@MAIN_NAME@Stub> 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; + } + int reference = pixels[0]; + int differing = 0; + for (int argb : pixels) { + int alpha = (argb >>> 24) & 0xFF; + if (alpha == 0) { + continue; + } + if (argb != reference) { + differing++; + if (differing > pixels.length / 100) { + return true; + } + } + } + return false; + } + + 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..4e732c23f0 --- /dev/null +++ b/scripts/update_android_ui_test_gradle.py @@ -0,0 +1,261 @@ +#!/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 + +COMPILE_SDK_LINE = " compileSdkVersion 35\n" + +TEST_OPTIONS_SNIPPET = """ testOptions {\n animationsDisabled = true\n }\n\n""" + +DEFAULT_CONFIG_SNIPPET = """ defaultConfig {\n testInstrumentationRunner \"androidx.test.runner.AndroidJUnitRunner\"\n }\n\n""" + +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", +) + + +class GradleFile: + def __init__(self, content: str) -> None: + self.content = content + self.configuration_used: str | None = None + self.added_dependencies: list[str] = [] + + def _iter_blocks(self): + 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_compile_sdk(self) -> None: + if re.search(r"compileSdkVersion\s+\d+", self.content): + 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] + COMPILE_SDK_LINE + self.content[insert:] + + 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 ensure_instrumentation_runner(self) -> None: + if "testInstrumentationRunner" in self.content: + return + default_block = self._find_block("defaultConfig", parent="android") + if default_block: + insert = self.content.find('\n', default_block[0]) + 1 + line = " testInstrumentationRunner \"androidx.test.runner.AndroidJUnitRunner\"\n" + self.content = ( + self.content[:insert] + line + self.content[insert:default_block[1]] + self.content[default_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] + DEFAULT_CONFIG_SNIPPET + self.content[insert:] + + 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_compile_sdk() + self.ensure_test_options() + self.ensure_instrumentation_runner() + 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) -> 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.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) + 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) + return 0 + + +if __name__ == "__main__": + sys.exit(main())