diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index aeadcab..96aae7c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -30,6 +30,7 @@ jobs: pull-requests: read outputs: run_ci: ${{ steps.filter.outputs.all_count != steps.filter.outputs.ignored_count }} + release_pr: ${{ github.event_name == 'pull_request' && github.event.pull_request.user.login == 'fossifybot[bot]' && github.event.pull_request.head.ref == 'fossifybot/release' && github.event.pull_request.head.repo.full_name == github.repository && startsWith(inputs.test_task, ':app:') }} steps: - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d id: filter @@ -47,7 +48,7 @@ jobs: wrapper-validation: needs: changes - if: needs.changes.outputs.run_ci == 'true' + if: needs.changes.outputs.run_ci == 'true' || needs.changes.outputs.release_pr == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 @@ -111,10 +112,123 @@ jobs: name: "lint-results" path: "app/build/intermediates/lint_intermediate_text_report/fossDebug/lint-results-fossDebug.txt" + release-build: + needs: [changes, wrapper-validation] + if: needs.changes.outputs.release_pr == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + outputs: + matrix: ${{ steps.prepare.outputs.matrix }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Checkout shared scripts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: FossifyOrg/.github + path: .github-repo + persist-credentials: false + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 + with: + java-version: 17 + distribution: "temurin" + - uses: FossifyOrg/.github/.github/actions/gradle-cache@main + - name: Build minified release APK + run: ./gradlew :app:assembleFossRelease + - name: Prepare APK for fresh emulators + id: prepare + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + apks=(app/build/outputs/apk/foss/release/*.apk) + [[ ${#apks[@]} == 1 ]] || { echo "::error::Expected one release APK"; exit 1; } + mapping="app/build/outputs/mapping/fossRelease/mapping.txt" + test -s "$mapping" + + build_tools=$(find "$ANDROID_HOME/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1) + mkdir -p release-smoke + keytool -genkeypair -noprompt -keystore "$RUNNER_TEMP/release-smoke.keystore" \ + -storepass android -keypass android -alias smoke -keyalg RSA -keysize 2048 \ + -validity 2 -dname 'CN=Emulator test' + "$build_tools/zipalign" -P 16 -f 4 "${apks[0]}" "$RUNNER_TEMP/release-smoke-aligned.apk" + "$build_tools/apksigner" sign --ks "$RUNNER_TEMP/release-smoke.keystore" \ + --ks-pass pass:android --key-pass pass:android \ + --out release-smoke/app.apk "$RUNNER_TEMP/release-smoke-aligned.apk" + "$build_tools/apksigner" verify release-smoke/app.apk + "$build_tools/zipalign" -P 16 -c 4 release-smoke/app.apk + cp "$mapping" release-smoke/ + cp .github-repo/scripts/emulator_smoke.py release-smoke/ + python3 release-smoke/emulator_smoke.py plan --apk release-smoke/app.apk \ + --output release-smoke/plan.json --github-output "$GITHUB_OUTPUT" + - name: Save release test files + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: release-smoke-apk + path: release-smoke/ + if-no-files-found: error + retention-days: 7 + + release-emulators: + name: Release launch checks (Android ${{ matrix.android }}) + needs: [changes, release-build] + if: needs.changes.outputs.release_pr == 'true' + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 2 + matrix: ${{ fromJSON(needs.release-build.outputs.matrix) }} + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: release-smoke-apk + path: release-smoke + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 + with: + java-version: 17 + distribution: "temurin" + - name: Enable emulator acceleration + run: sudo chown "$(id -u)" /dev/kvm + - name: Use command-line tools for Android 17 + if: matrix.api == 37 + run: | + sdk_tools="$ANDROID_HOME/cmdline-tools" + sdk_version=$("$sdk_tools/latest/bin/sdkmanager" --version | tail -n 1) + if ! [ "${sdk_version%%.*}" -ge 22 ] 2>/dev/null; then + "$sdk_tools/latest/bin/sdkmanager" --install "cmdline-tools;22.0" + mv "$sdk_tools/latest" "$RUNNER_TEMP/preinstalled-cmdline-tools" + ln -s "$sdk_tools/22.0" "$sdk_tools/latest" + fi + "$sdk_tools/latest/bin/sdkmanager" --version + - name: Launch release activities + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d + with: + api-level: ${{ matrix.image }} + target: google_apis + arch: x86_64 + ram-size: 2048M + cores: 2 + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back emulated -camera-front emulated + script: python3 release-smoke/emulator_smoke.py run --apk release-smoke/app.apk --serial emulator-5554 --api ${{ matrix.api }} --output launch-results + - name: Save launch results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: release-launch-android-${{ matrix.android }} + path: launch-results/ + if-no-files-found: warn + retention-days: 7 + ci-gate: if: always() runs-on: ubuntu-latest - needs: [changes, wrapper-validation, test, detekt, lint] + needs: [changes, wrapper-validation, test, detekt, lint, release-build, release-emulators] steps: - name: Enforce required checks run: | @@ -125,6 +239,16 @@ jobs: echo "test: ${{ needs.test.result }}" echo "detekt: ${{ needs.detekt.result }} (input run_detekt=${{ inputs.run_detekt }})" echo "lint: ${{ needs.lint.result }} (input run_lint=${{ inputs.run_lint }})" + echo "release-build: ${{ needs.release-build.result }}" + echo "release-emulators: ${{ needs.release-emulators.result }}" + + [[ "${{ needs.changes.result }}" == "success" ]] || exit 1 + + if [[ "${{ needs.changes.outputs.release_pr }}" == "true" ]]; then + [[ "${{ needs.wrapper-validation.result }}" == "success" ]] || exit 1 + [[ "${{ needs.release-build.result }}" == "success" ]] || exit 1 + [[ "${{ needs.release-emulators.result }}" == "success" ]] || exit 1 + fi if [[ "${{ needs.changes.outputs.run_ci }}" != "true" ]]; then exit 0 diff --git a/scripts/emulator_smoke.py b/scripts/emulator_smoke.py new file mode 100644 index 0000000..e767756 --- /dev/null +++ b/scripts/emulator_smoke.py @@ -0,0 +1,354 @@ +"""Launch an APK's enabled exported activities on a fresh Android emulator.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shlex +import shutil +import subprocess +import time +import xml.etree.ElementTree as ET + + +ANDROID = "{http://schemas.android.com/apk/res/android}" +VERSIONS = [("8", 26, "26"), ("10", 29, "29"), ("12", 31, "31"), + ("15", 35, "35"), ("16", 36, "36"), ("17", 37, "37.0")] +NAME = re.compile(r"[A-Za-z_$][A-Za-z0-9_.$]*\Z") + + +class SmokeError(RuntimeError): + pass + + +def android_bool(element, name, default): + value = element.get(ANDROID + name) + if value is None: + return default + if value not in ("true", "false"): + raise SmokeError(f"Cannot read android:{name}={value}") + return value == "true" + + +def full_name(package, name): + if not name: + raise SmokeError("Activity name is missing") + if name.startswith("."): + name = package + name + elif "." not in name: + name = package + "." + name + if not NAME.fullmatch(name): + raise SmokeError(f"Invalid component name: {name}") + return name + + +def parse_manifest(text): + root = ET.fromstring(text) + package = root.get("package", "") + if not NAME.fullmatch(package): + raise SmokeError("APK has no valid package name") + sdk = root.find("uses-sdk") + minimum = int(sdk.get(ANDROID + "minSdkVersion", "1")) if sdk is not None else 1 + app = root.find("application") + if app is None or not android_bool(app, "enabled", True): + raise SmokeError("APK application is missing or disabled") + if android_bool(app, "debuggable", False): + raise SmokeError("Expected a non-debuggable release APK") + + activities = [] + home_activity = None + for element in app: + if element.tag not in ("activity", "activity-alias"): + continue + name = full_name(package, element.get(ANDROID + "name", "")) + filters = element.findall("intent-filter") + launcher = any( + any(a.get(ANDROID + "name") == "android.intent.action.MAIN" + for a in f.findall("action")) + and any(c.get(ANDROID + "name") == "android.intent.category.LAUNCHER" + for c in f.findall("category")) + for f in filters + ) + if (android_bool(element, "enabled", True) + and android_bool(element, "exported", bool(filters)) + and any(c.get(ANDROID + "name") == "android.intent.category.HOME" + for f in filters for c in f.findall("category"))): + home_activity = name + activities.append({ + "name": name, + "target": full_name(package, element.get(ANDROID + "targetActivity", name)), + "enabled": android_bool(element, "enabled", True), + "exported": android_bool(element, "exported", bool(filters)), + "launcher": launcher, + "permission": element.get(ANDROID + "permission", app.get(ANDROID + "permission")), + "actions": sorted({a.get(ANDROID + "name") for f in filters for a in f.findall("action")}), + "has_intent_data": any(f.find("data") is not None for f in filters), + }) + if not any(a["enabled"] and a["exported"] and a["launcher"] for a in activities): + raise SmokeError("No enabled exported launcher in APK") + roles = [] + if home_activity: + roles.append("HOME") + if any(a.get(ANDROID + "name") == "android.provider.Telephony.SMS_DELIVER" + for a in app.findall("receiver/intent-filter/action")): + roles.append("SMS") + if any(s.get(ANDROID + "permission") == "android.permission.BIND_INCALL_SERVICE" + for s in app.findall("service")): + roles.append("DIALER") + permissions = sorted({p.get(ANDROID + "name") for p in root.findall("uses-permission") + + root.findall("uses-permission-sdk-23") if p.get(ANDROID + "name")}) + return {"package": package, "minimum_sdk": minimum, "activities": activities, + "roles": roles, "home_activity": home_activity, "permissions": permissions} + + +def command(args, timeout=30): + result = subprocess.run(args, capture_output=True, text=True, timeout=timeout) + if result.returncode: + raise SmokeError(f"Command failed: {shlex.join(args)}\n{result.stdout}{result.stderr}") + return result.stdout + + +def read_apk(apk): + analyzer = shutil.which("apkanalyzer") + if not analyzer: + sdk = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT") + if sdk: + analyzer = str(Path(sdk) / "cmdline-tools/latest/bin/apkanalyzer") + if not analyzer: + raise SmokeError("apkanalyzer is required") + return parse_manifest(command([analyzer, "manifest", "print", str(apk)], timeout=60)) + + +def version_matrix(minimum): + include = [{"android": version, "api": api, "image": image} + for version, api, image in VERSIONS if api >= minimum] + if not include: + raise SmokeError("APK minimum SDK exceeds every requested Android version") + omitted = [f"Android {version}: API {api} is below the APK minimum SDK {minimum}" + for version, api, _ in VERSIONS if api < minimum] + return {"include": include}, omitted + + +def faults(log, package): + process = re.escape(package) + r"(?::[A-Za-z0-9_.]+)?" + patterns = [ + r"FATAL EXCEPTION:[^\n]*\n(?:[^\n]*\n){0,5}[^\n]*Process: " + process + r"(?=,|\s|$)", + r"\bAndroidRuntime\b[^\n]*\bProcess: " + process + r"(?=,|\s|$)", + r"ANR in " + process + r"(?=\s|,|$)", + r"am_(?:crash|anr)\s*:\s*\[\s*\d+,\s*\d+,\s*" + process + r"\s*,", + r">>> " + process + r" <<<", + ] + return [match.group(0) for pattern in patterns for match in re.finditer(pattern, log)] + + +def resumed_package(text, package): + return any(re.search(r"(?:mResumedActivity|topResumedActivity)", line) + and re.search(r"\s" + re.escape(package) + r"/", line) + for line in text.splitlines()) + + +class Emulator: + def __init__(self, serial): + if not re.fullmatch(r"emulator-\d+", serial): + raise SmokeError("Only a fresh emulator serial is accepted") + self.serial = serial + + def adb(self, *args, timeout=30): + return command(["adb", "-s", self.serial, *args], timeout) + + def shell(self, *args, timeout=30): + return self.adb("shell", shlex.join(args), timeout=timeout) + + def check_device(self, expected_api, minimum): + if not any(self.shell("getprop", name).strip() == "1" + for name in ("ro.boot.qemu", "ro.kernel.qemu")): + raise SmokeError("Device is not an Android emulator") + api = int(self.shell("getprop", "ro.build.version.sdk").strip()) + if expected_api is not None and api != expected_api: + raise SmokeError(f"Expected API {expected_api}, emulator reports {api}") + if api < minimum: + raise SmokeError(f"Emulator API {api} is below APK minimum SDK {minimum}") + return api + + def set_roles(self, roles, package, api, home_activity=None): + for role in roles: + if api >= 29: + self.shell("cmd", "role", "add-role-holder", "--user", "0", + "android.app.role." + role, package) + elif role == "SMS": + self.shell("settings", "put", "secure", "sms_default_application", package) + self.shell("appops", "set", package, "WRITE_SMS", "allow") + if self.shell("settings", "get", "secure", "sms_default_application").strip() != package: + raise SmokeError("Could not select default SMS app") + elif role == "DIALER": + self.shell("telecom", "set-default-dialer", package) + if self.shell("telecom", "get-default-dialer").strip() != package: + raise SmokeError("Could not select default dialer app") + elif role == "HOME": + self.shell("cmd", "package", "set-home-activity", "--user", "0", + package + "/" + home_activity) + + def set_storage_access(self, permissions, package, api): + if api >= 30 and "android.permission.MANAGE_EXTERNAL_STORAGE" in permissions: + self.shell("appops", "set", "--uid", package, "MANAGE_EXTERNAL_STORAGE", "allow") + access = self.shell("appops", "get", "--uid", package, "MANAGE_EXTERNAL_STORAGE") + if not re.search(r"MANAGE_EXTERNAL_STORAGE:\s*allow\b", access): + raise SmokeError("Could not grant the APK's declared all-files access") + + +def write_report(output, report): + output.mkdir(parents=True, exist_ok=True) + (output / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + lines = [f"Release launch checks: {report.get('package', 'APK')} (API {report.get('api', '?')})", "", + "Activity | Result", "--- | ---"] + lines += [f"{a['name']} | {a['result']}" for a in report["activities"]] + lines += ["", "These checks cover startup only. Internal screens and data-dependent flows are not exercised."] + lines += ["", *report["errors"]] + summary = "\n".join(lines) + "\n" + (output / "report.md").write_text(summary, encoding="utf-8") + if os.environ.get("GITHUB_STEP_SUMMARY"): + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as stream: + stream.write(summary) + print(summary) + + +def run_checks(args): + output = args.output + output.mkdir(parents=True, exist_ok=True) + report = {"activities": [], "errors": [], "status": "failed"} + emulator = None + diagnostics_allowed = False + try: + plan = read_apk(args.apk) + report.update({k: v for k, v in plan.items() if k != "activities"}) + with args.apk.open("rb") as apk_file: + report["apk_sha256"] = hashlib.file_digest(apk_file, "sha256").hexdigest() + emulator = Emulator(args.serial) + api = emulator.check_device(args.api, plan["minimum_sdk"]) + report["api"] = api + package = plan["package"] + packages = emulator.shell("pm", "list", "packages", "--user", "0").splitlines() + if f"package:{package}" in packages: + raise SmokeError("APK package is already installed; use a fresh emulator") + emulator.adb("logcat", "-c") + installed = emulator.adb("install", "-g", str(args.apk.resolve()), timeout=120) + if "Success" not in installed: + raise SmokeError("APK installation did not report success: " + installed) + diagnostics_allowed = True + emulator.set_storage_access(plan.get("permissions", []), package, api) + emulator.set_roles(plan["roles"], package, api, plan["home_activity"]) + attempted = set() + report["activities"] = [{"name": a["name"], "result": "not attempted"} + for a in plan["activities"]] + entries = {entry["name"]: entry for entry in report["activities"]} + for activity in sorted(plan["activities"], key=lambda a: not a["launcher"]): + entry = entries[activity["name"]] + if not activity["enabled"]: + entry["result"] = "disabled" + continue + if not activity["exported"]: + entry["result"] = "internal; needs navigation" + continue + if activity["target"] in attempted: + entry["result"] = "same target already started" + continue + if activity["permission"] and not activity["launcher"]: + entry["result"] = "protected activity; needs permitted caller" + continue + if not activity["launcher"] and (activity["has_intent_data"] or + "android.appwidget.action.APPWIDGET_CONFIGURE" in activity["actions"]): + entry["result"] = "needs intent data; not started" + continue + attempted.add(activity["target"]) + emulator.shell("am", "force-stop", package) + emulator.shell("input", "keyevent", "KEYCODE_HOME") + start = ["am", "start", "-W", "-n", package + "/" + activity["name"]] + if activity["launcher"]: + start += ["-a", "android.intent.action.MAIN", "-c", "android.intent.category.LAUNCHER"] + launch = emulator.shell(*start, timeout=45) + (output / f"launch-{len(attempted)}.txt").write_text(launch, encoding="utf-8") + if re.search(r"Error:|Exception|Permission Denial", launch): + raise SmokeError(f"Could not start {activity['name']}: {launch}") + if "Status: ok" not in launch: + raise SmokeError(f"Launch did not report success for {activity['name']}: {launch}") + emulator.shell("input", "keyevent", "KEYCODE_TAB") + time.sleep(args.observe_seconds) + log = emulator.adb("logcat", "-b", "all", "-d", "-v", "threadtime", timeout=45) + (output / "logcat.txt").write_text(log, encoding="utf-8") + crashes = faults(log, package) + if crashes: + entry["result"] = "crash or ANR" + raise SmokeError("App crash or ANR detected: " + "\n".join(crashes)) + activities = emulator.shell("dumpsys", "activity", "activities") + foreground = resumed_package(activities, package) + if activity["launcher"] and not foreground: + entry["result"] = "launcher did not reach app" + raise SmokeError("Launcher did not leave an app activity resumed after setup") + entry["result"] = "started; app resumed" if foreground else "started then returned; needs input or navigation" + if activity["has_intent_data"]: + entry["note"] = "No intent data supplied; data-dependent behavior is not tested" + report["status"] = "passed" + except (SmokeError, OSError, ValueError, ET.ParseError, subprocess.TimeoutExpired) as error: + report["errors"].append(str(error)) + if emulator and diagnostics_allowed: + for filename, cmd in [("logcat.txt", ["logcat", "-b", "all", "-d", "-v", "threadtime"]), + ("activities.txt", ["shell", "dumpsys activity activities"])]: + try: + (output / filename).write_text(emulator.adb(*cmd, timeout=45), encoding="utf-8") + except (SmokeError, OSError, subprocess.TimeoutExpired) as diagnostic_error: + report["errors"].append(f"Could not save {filename}: {diagnostic_error}") + try: + with (output / "failure.png").open("wb") as screenshot: + subprocess.run(["adb", "-s", args.serial, "exec-out", "screencap", "-p"], + stdout=screenshot, stderr=subprocess.PIPE, timeout=20, check=True) + except (OSError, subprocess.SubprocessError) as diagnostic_error: + report["errors"].append(f"Could not save screenshot: {diagnostic_error}") + write_report(output, report) + return 0 if report["status"] == "passed" else 1 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + plan = commands.add_parser("plan") + plan.add_argument("--apk", required=True, type=Path) + plan.add_argument("--output", required=True, type=Path) + plan.add_argument("--github-output", type=Path) + run = commands.add_parser("run") + run.add_argument("--apk", required=True, type=Path) + run.add_argument("--serial", required=True) + run.add_argument("--api", type=int) + run.add_argument("--output", required=True, type=Path) + run.add_argument("--observe-seconds", type=float, default=7) + args = parser.parse_args() + if args.command == "run": + if args.observe_seconds <= 0: + parser.error("--observe-seconds must be positive") + return run_checks(args) + try: + info = read_apk(args.apk) + info["matrix"], info["omitted_versions"] = version_matrix(info["minimum_sdk"]) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(info, indent=2) + "\n", encoding="utf-8") + if args.github_output: + with args.github_output.open("a", encoding="utf-8") as stream: + stream.write("matrix=" + json.dumps(info["matrix"], separators=(",", ":")) + "\n") + print(json.dumps(info["matrix"])) + for omission in info["omitted_versions"]: + print(omission) + if os.environ.get("GITHUB_STEP_SUMMARY"): + versions = ", ".join(item["android"] for item in info["matrix"]["include"]) + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as stream: + stream.write(f"Release launch checks: Android {versions}\n\n") + for omission in info["omitted_versions"]: + stream.write(f"- Skipped {omission}\n") + return 0 + except (SmokeError, OSError, ValueError, ET.ParseError, subprocess.TimeoutExpired) as error: + print(f"::error::{error}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())