From 458b8c4a30e5d39f3b0508dd923db7eff55efe55 Mon Sep 17 00:00:00 2001 From: Marvin <295208132+marvinctl@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:10:12 +0530 Subject: [PATCH 1/8] ci: check minified release APKs on Android emulators --- .github/workflows/pr.yml | 137 ++++++++++++++- docs/release-emulators.md | 30 ++++ scripts/emulator_smoke.py | 348 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 513 insertions(+), 2 deletions(-) create mode 100644 docs/release-emulators.md create mode 100644 scripts/emulator_smoke.py diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index aeadcab..f709d83 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -18,6 +18,11 @@ on: type: string default: ":app:testFossDebugUnitTest" required: false + shared_ref: + description: "Shared script revision; match the workflow revision when using a branch, tag or commit." + type: string + default: "main" + required: false concurrency: group: pr-ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -30,6 +35,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 +53,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 +117,127 @@ 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 + env: + RELEASE_VARIANT: ${{ startsWith(inputs.test_task, ':app:testCore') && 'Core' || 'Foss' }} + 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 + ref: ${{ inputs.shared_ref }} + 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:assemble${RELEASE_VARIANT}Release" + - name: Prepare APK for fresh emulators + id: prepare + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + variant=${RELEASE_VARIANT,,} + apks=(app/build/outputs/apk/"$variant"/release/*.apk) + [[ ${#apks[@]} == 1 ]] || { echo "::error::Expected one release APK"; exit 1; } + mapping="app/build/outputs/mapping/${variant}Release/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@1dcd0090116d15e7c562f8db72807de5e036a4ed + 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 none -camera-front none + 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 +248,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/docs/release-emulators.md b/docs/release-emulators.md new file mode 100644 index 0000000..4ff35ce --- /dev/null +++ b/docs/release-emulators.md @@ -0,0 +1,30 @@ +App release PRs from `fossifybot/release` build one minified release APK and reuse it for Android 8, 10, 12, 15, 16 and 17. The existing app test task selects the FOSS variant, or Core for apps using `:app:testCore...`. Library PRs do not start emulator jobs. The emulator matrix runs at most two jobs per app at a time. Other PRs keep their existing checks. Release checks also run when a PR changes only store notes or the changelog. + +The APK gets a temporary test signature, without production signing secrets. The existing signed test-build workflow and its PR comment are unchanged. The build must produce an R8 map and a non-debuggable APK; alignment and signing are checked before upload. Android 17 uses Google's `android-37.0` image and command-line tools 22.0 or newer to avoid older AVD tools writing `target=android-0` ([upstream issue](https://github.com/ReactiveCircus/android-emulator-runner/issues/482)); Android 8/10/12/15/16 use API 26/29/31/35/36. The Android 17 setup can be removed once runner images consistently provide the newer tools. Versions below the APK's minimum SDK are listed in the job summary and `plan.json`, then omitted from the matrix. A missing SDK image, failed build, failed launch, crash, ANR or skipped required job fails the final PR check. + +The shared runner reads the packaged manifest, installs only on a fresh emulator, grants the APK's runtime permissions and declared all-files access, and selects the default SMS or dialer role when declared. It starts the enabled launcher first and requires an app activity to be resumed after setup. It then attempts the other eligible exported activities, sending a Tab key event and checking logs after each start. Internal activities, disabled icon aliases, duplicate alias targets, protected activities, file/share handlers and widget configuration screens are listed without supplying their required inputs. Activities that return without input are reported as needing further testing. The runner supplies no phone numbers, message bodies, accounts or personal files and does not invoke send, call or record actions. + +This is a startup check, not complete screen testing. Activities that need a file, widget ID or other intent data still need later navigation or sample-input tests. A test signature also cannot check interactions that require the production signing key. Crash checks only cover the brief observation period after each launch. The Tab key event supplies an input event so an unresponsive screen can reach Android's input-ANR timeout during observation. + +The one APK download includes the runner and R8 map used for that run. Per-version results include the launch output and an activity report; failures also save logcat, activity state and a screenshot. These files expire after seven days. There are no PR comments or release changes. + +For a local check, start a disposable emulator and use its serial: + +```sh +python3 scripts/emulator_smoke.py plan --apk app.apk --output plan.json +python3 scripts/emulator_smoke.py run --apk app.apk --serial emulator-5554 --api 29 --output launch-results +``` + +The runner refuses physical-device serials, an already-installed APK package and emulators with user-installed apps. It does not clear app data, replace an installation, restart ADB or change a running emulator's image. Use a new emulator rather than an existing personal AVD. + +Normal app callers need no changes. When calling a shared workflow revision other than `main`, set `shared_ref` to that same revision so the downloaded helper matches it: + +```yaml +jobs: + checks: + uses: FossifyOrg/.github/.github/workflows/pr.yml@REVISION + with: + shared_ref: REVISION +``` + +Later app-specific instrumentation can use these same emulator versions and result uploads. Keep the tests, generated input files and Gradle test setup in each app. Testing release behavior will also need compatible app/test signatures and R8 rules; running debug tests alone would miss release-only crashes. This PR adds startup checks only, not playback, recording or message-delivery tests. diff --git a/scripts/emulator_smoke.py b/scripts/emulator_smoke.py new file mode 100644 index 0000000..248d8d0 --- /dev/null +++ b/scripts/emulator_smoke.py @@ -0,0 +1,348 @@ +"""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 = [] + 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 + ) + 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 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, "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): + for role in roles: + if api >= 29: + self.shell("cmd", "role", "add-role-holder", "--user", "0", + "android.app.role." + role, package) + holders = self.shell("cmd", "role", "get-role-holders", "--user", "0", + "android.app.role." + role).splitlines() + if package not in holders: + raise SmokeError(f"Could not select default {role} app") + 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") + else: + self.shell("cmd", "telecom", "set-default-dialer", package) + if self.shell("cmd", "telecom", "get-default-dialer").strip() != package: + raise SmokeError("Could not select default dialer app") + + 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"] + if emulator.shell("pm", "path", package).strip(): + raise SmokeError("APK package is already installed; use a fresh emulator") + if emulator.shell("pm", "list", "packages", "-3").strip(): + raise SmokeError("Emulator has user-installed apps; use a fresh one") + 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) + 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()) From 9534cc72e7744e52d72e6c917fe6cdd578b971fb Mon Sep 17 00:00:00 2001 From: Marvin <295208132+marvinctl@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:20:52 +0530 Subject: [PATCH 2/8] fix: correct emulator install and default-role checks --- .github/workflows/pr.yml | 2 + docs/release-emulators.md | 30 ------ scripts/emulator_smoke.py | 30 +++--- scripts/test_emulator_smoke.py | 176 +++++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 41 deletions(-) delete mode 100644 docs/release-emulators.md create mode 100644 scripts/test_emulator_smoke.py diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f709d83..f4495b4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -139,6 +139,8 @@ jobs: ref: ${{ inputs.shared_ref }} path: .github-repo persist-credentials: false + - name: Test launch-check helper + run: python3 -m unittest discover -s .github-repo/scripts -p 'test_emulator_smoke.py' - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 with: java-version: 17 diff --git a/docs/release-emulators.md b/docs/release-emulators.md deleted file mode 100644 index 4ff35ce..0000000 --- a/docs/release-emulators.md +++ /dev/null @@ -1,30 +0,0 @@ -App release PRs from `fossifybot/release` build one minified release APK and reuse it for Android 8, 10, 12, 15, 16 and 17. The existing app test task selects the FOSS variant, or Core for apps using `:app:testCore...`. Library PRs do not start emulator jobs. The emulator matrix runs at most two jobs per app at a time. Other PRs keep their existing checks. Release checks also run when a PR changes only store notes or the changelog. - -The APK gets a temporary test signature, without production signing secrets. The existing signed test-build workflow and its PR comment are unchanged. The build must produce an R8 map and a non-debuggable APK; alignment and signing are checked before upload. Android 17 uses Google's `android-37.0` image and command-line tools 22.0 or newer to avoid older AVD tools writing `target=android-0` ([upstream issue](https://github.com/ReactiveCircus/android-emulator-runner/issues/482)); Android 8/10/12/15/16 use API 26/29/31/35/36. The Android 17 setup can be removed once runner images consistently provide the newer tools. Versions below the APK's minimum SDK are listed in the job summary and `plan.json`, then omitted from the matrix. A missing SDK image, failed build, failed launch, crash, ANR or skipped required job fails the final PR check. - -The shared runner reads the packaged manifest, installs only on a fresh emulator, grants the APK's runtime permissions and declared all-files access, and selects the default SMS or dialer role when declared. It starts the enabled launcher first and requires an app activity to be resumed after setup. It then attempts the other eligible exported activities, sending a Tab key event and checking logs after each start. Internal activities, disabled icon aliases, duplicate alias targets, protected activities, file/share handlers and widget configuration screens are listed without supplying their required inputs. Activities that return without input are reported as needing further testing. The runner supplies no phone numbers, message bodies, accounts or personal files and does not invoke send, call or record actions. - -This is a startup check, not complete screen testing. Activities that need a file, widget ID or other intent data still need later navigation or sample-input tests. A test signature also cannot check interactions that require the production signing key. Crash checks only cover the brief observation period after each launch. The Tab key event supplies an input event so an unresponsive screen can reach Android's input-ANR timeout during observation. - -The one APK download includes the runner and R8 map used for that run. Per-version results include the launch output and an activity report; failures also save logcat, activity state and a screenshot. These files expire after seven days. There are no PR comments or release changes. - -For a local check, start a disposable emulator and use its serial: - -```sh -python3 scripts/emulator_smoke.py plan --apk app.apk --output plan.json -python3 scripts/emulator_smoke.py run --apk app.apk --serial emulator-5554 --api 29 --output launch-results -``` - -The runner refuses physical-device serials, an already-installed APK package and emulators with user-installed apps. It does not clear app data, replace an installation, restart ADB or change a running emulator's image. Use a new emulator rather than an existing personal AVD. - -Normal app callers need no changes. When calling a shared workflow revision other than `main`, set `shared_ref` to that same revision so the downloaded helper matches it: - -```yaml -jobs: - checks: - uses: FossifyOrg/.github/.github/workflows/pr.yml@REVISION - with: - shared_ref: REVISION -``` - -Later app-specific instrumentation can use these same emulator versions and result uploads. Keep the tests, generated input files and Gradle test setup in each app. Testing release behavior will also need compatible app/test signatures and R8 rules; running debug tests alone would miss release-only crashes. This PR adds startup checks only, not playback, recording or message-delivery tests. diff --git a/scripts/emulator_smoke.py b/scripts/emulator_smoke.py index 248d8d0..35daf09 100644 --- a/scripts/emulator_smoke.py +++ b/scripts/emulator_smoke.py @@ -58,6 +58,7 @@ def parse_manifest(text): 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 @@ -70,6 +71,11 @@ def parse_manifest(text): 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)), @@ -83,6 +89,8 @@ def parse_manifest(text): 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") @@ -92,7 +100,7 @@ def parse_manifest(text): 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, "permissions": permissions} + "roles": roles, "home_activity": home_activity, "permissions": permissions} def command(args, timeout=30): @@ -164,24 +172,23 @@ def check_device(self, expected_api, minimum): raise SmokeError(f"Emulator API {api} is below APK minimum SDK {minimum}") return api - def set_roles(self, roles, package, 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) - holders = self.shell("cmd", "role", "get-role-holders", "--user", "0", - "android.app.role." + role).splitlines() - if package not in holders: - raise SmokeError(f"Could not select default {role} app") 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") - else: - self.shell("cmd", "telecom", "set-default-dialer", package) - if self.shell("cmd", "telecom", "get-default-dialer").strip() != package: + 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: @@ -222,7 +229,8 @@ def run_checks(args): api = emulator.check_device(args.api, plan["minimum_sdk"]) report["api"] = api package = plan["package"] - if emulator.shell("pm", "path", package).strip(): + 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") if emulator.shell("pm", "list", "packages", "-3").strip(): raise SmokeError("Emulator has user-installed apps; use a fresh one") @@ -232,7 +240,7 @@ def run_checks(args): 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) + 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"]] diff --git a/scripts/test_emulator_smoke.py b/scripts/test_emulator_smoke.py new file mode 100644 index 0000000..6780d14 --- /dev/null +++ b/scripts/test_emulator_smoke.py @@ -0,0 +1,176 @@ +import argparse +from pathlib import Path +import shlex +import subprocess +import tempfile +import unittest +from unittest import mock + +import emulator_smoke as smoke + + +PACKAGE = "org.fossify.testapp" +MANIFEST = f""" + + + + + + + + + +""" + + +class RunnerTests(unittest.TestCase): + def run_checks(self, packages="", third_party="", query_error=False, log="", foreground=True): + calls = [] + + def respond(args, **kwargs): + self.assertEqual(args[:3], ["adb", "-s", "emulator-5554"]) + cmd = tuple(shlex.split(args[4]) if args[3] == "shell" else args[3:]) + calls.append(cmd) + code, output = 0, "" + if cmd == ("getprop", "ro.boot.qemu"): + output = "1\n" + elif cmd == ("getprop", "ro.build.version.sdk"): + output = "29\n" + elif cmd == ("pm", "path", PACKAGE): + if f"package:{PACKAGE}" in packages.splitlines(): + output = "package:/data/app/base.apk\n" + else: + code = 1 + elif cmd == ("pm", "list", "packages", "--user", "0"): + code, output = (1 if query_error else 0), packages + elif cmd == ("pm", "list", "packages", "-3"): + output = third_party + elif cmd[0] == "install": + output = "Success\n" + elif cmd[:2] == ("am", "start"): + output = "Status: ok\n" + elif cmd[:1] == ("logcat",): + output = log + elif cmd == ("dumpsys", "activity", "activities"): + resumed = PACKAGE if foreground else "com.android.permissioncontroller" + output = f"mResumedActivity: ActivityRecord{{123 u0 {resumed}/.MainActivity t1}}" + return subprocess.CompletedProcess(args, code, output, "") + + with tempfile.TemporaryDirectory() as directory: + apk = Path(directory) / "app.apk" + apk.write_bytes(b"Test input; manifest parsing is mocked") + args = argparse.Namespace(apk=apk, serial="emulator-5554", api=29, + output=Path(directory) / "results", observe_seconds=0) + with mock.patch.object(smoke, "read_apk", return_value=smoke.parse_manifest(MANIFEST)), \ + mock.patch.object(smoke.subprocess, "run", side_effect=respond), \ + mock.patch.object(smoke.time, "sleep"), \ + mock.patch.object(smoke, "write_report") as report: + status = smoke.run_checks(args) + return status, report.call_args.args[1], calls + + def test_fresh_package_reaches_install_and_launch(self): + status, report, calls = self.run_checks(packages="package:android\n") + self.assertEqual(status, 0, report["errors"]) + self.assertTrue(any(cmd[0] == "install" for cmd in calls)) + self.assertTrue(any(cmd[:2] == ("am", "start") for cmd in calls)) + self.assertNotIn(("pm", "path", PACKAGE), calls) + + def test_existing_package_is_not_replaced(self): + status, report, calls = self.run_checks(packages=f"package:{PACKAGE}\n") + self.assertEqual(status, 1) + self.assertIn("already installed", report["errors"][0]) + self.assertFalse(any(cmd[0] == "install" for cmd in calls)) + + def test_package_name_prefix_is_not_an_existing_install(self): + status, report, _ = self.run_checks(packages=f"package:{PACKAGE}.system\n") + self.assertEqual(status, 0, report["errors"]) + + def test_failed_package_query_stops_before_install(self): + status, report, calls = self.run_checks(query_error=True) + self.assertEqual(status, 1) + self.assertIn("pm list packages --user 0", report["errors"][0]) + self.assertFalse(any(cmd[0] == "install" for cmd in calls)) + + def test_other_user_installed_apps_are_refused(self): + status, report, calls = self.run_checks(third_party="package:org.example.personal\n") + self.assertEqual(status, 1) + self.assertIn("user-installed apps", report["errors"][0]) + self.assertFalse(any(cmd[0] == "install" for cmd in calls)) + + def test_launcher_crash_fails(self): + status, report, _ = self.run_checks(log=f"FATAL EXCEPTION: main\nProcess: {PACKAGE}, PID: 123\n") + self.assertEqual(status, 1) + self.assertEqual(report["activities"][0]["result"], "crash or ANR") + + def test_system_role_dialog_is_not_an_app_launch(self): + status, report, _ = self.run_checks(foreground=False) + self.assertEqual(status, 1) + self.assertEqual(report["activities"][0]["result"], "launcher did not reach app") + + +class RoleTests(unittest.TestCase): + def test_home_activity_and_launcher_alias_are_detected(self): + manifest = MANIFEST.replace("android.intent.category.LAUNCHER", "android.intent.category.HOME") + manifest = manifest.replace("", """ + + + + + + + """) + plan = smoke.parse_manifest(manifest) + self.assertIn("HOME", plan["roles"]) + self.assertEqual(plan["home_activity"], PACKAGE + ".MainActivity") + + def test_android_10_and_12_use_supported_role_commands(self): + for api in (29, 31): + with self.subTest(api=api): + emulator = smoke.Emulator("emulator-5554") + + def shell(*cmd, **kwargs): + if cmd[:3] != ("cmd", "role", "add-role-holder"): + raise smoke.SmokeError("Unsupported role command") + return "" + + with mock.patch.object(emulator, "shell", side_effect=shell) as run: + emulator.set_roles(["SMS", "DIALER", "HOME"], PACKAGE, api) + self.assertEqual(run.call_args_list, [ + mock.call("cmd", "role", "add-role-holder", "--user", "0", + "android.app.role." + role, PACKAGE) + for role in ("SMS", "DIALER", "HOME") + ]) + + def test_role_assignment_failure_is_not_ignored(self): + emulator = smoke.Emulator("emulator-5554") + with mock.patch.object(smoke.subprocess, "run", return_value=subprocess.CompletedProcess( + [], 1, "", "Role assignment failed")): + with self.assertRaises(smoke.SmokeError): + emulator.set_roles(["SMS"], PACKAGE, 29) + + def test_android_8_uses_standalone_telecom(self): + emulator = smoke.Emulator("emulator-5554") + + def shell(*cmd, **kwargs): + if cmd[0] != "telecom": + raise smoke.SmokeError("Unsupported telecom command") + return PACKAGE if cmd[1] == "get-default-dialer" else "Success" + + with mock.patch.object(emulator, "shell", side_effect=shell) as run: + emulator.set_roles(["DIALER"], PACKAGE, 26) + self.assertEqual(run.call_args_list, [ + mock.call("telecom", "set-default-dialer", PACKAGE), + mock.call("telecom", "get-default-dialer"), + ]) + + def test_android_8_home_uses_a_component(self): + emulator = smoke.Emulator("emulator-5554") + with mock.patch.object(emulator, "shell", return_value="Success") as run: + emulator.set_roles(["HOME"], PACKAGE, 26, PACKAGE + ".MainActivity") + run.assert_called_once_with("cmd", "package", "set-home-activity", "--user", "0", + PACKAGE + "/" + PACKAGE + ".MainActivity") + + +if __name__ == "__main__": + unittest.main() From 658155f89d512034b4c9b7b71f7aa04c9ed0f7a5 Mon Sep 17 00:00:00 2001 From: Marvin <295208132+marvinctl@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:19:27 +0530 Subject: [PATCH 3/8] fix(ci): remove obsolete Core release flavour --- .github/workflows/pr.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f4495b4..a2eb037 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -124,8 +124,6 @@ jobs: timeout-minutes: 30 permissions: contents: read - env: - RELEASE_VARIANT: ${{ startsWith(inputs.test_task, ':app:testCore') && 'Core' || 'Foss' }} outputs: matrix: ${{ steps.prepare.outputs.matrix }} steps: @@ -147,17 +145,16 @@ jobs: distribution: "temurin" - uses: FossifyOrg/.github/.github/actions/gradle-cache@main - name: Build minified release APK - run: ./gradlew ":app:assemble${RELEASE_VARIANT}Release" + run: ./gradlew :app:assembleFossRelease - name: Prepare APK for fresh emulators id: prepare shell: bash run: | set -euo pipefail shopt -s nullglob - variant=${RELEASE_VARIANT,,} - apks=(app/build/outputs/apk/"$variant"/release/*.apk) + apks=(app/build/outputs/apk/foss/release/*.apk) [[ ${#apks[@]} == 1 ]] || { echo "::error::Expected one release APK"; exit 1; } - mapping="app/build/outputs/mapping/${variant}Release/mapping.txt" + 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) From 5b42e992e8040f1b1570e5f5cc1bc30d850beee9 Mon Sep 17 00:00:00 2001 From: Marvin <295208132+marvinctl@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:48:26 +0530 Subject: [PATCH 4/8] ci: remove helper mock tests --- .github/workflows/pr.yml | 2 - scripts/test_emulator_smoke.py | 176 --------------------------------- 2 files changed, 178 deletions(-) delete mode 100644 scripts/test_emulator_smoke.py diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a2eb037..7b9c915 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -137,8 +137,6 @@ jobs: ref: ${{ inputs.shared_ref }} path: .github-repo persist-credentials: false - - name: Test launch-check helper - run: python3 -m unittest discover -s .github-repo/scripts -p 'test_emulator_smoke.py' - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 with: java-version: 17 diff --git a/scripts/test_emulator_smoke.py b/scripts/test_emulator_smoke.py deleted file mode 100644 index 6780d14..0000000 --- a/scripts/test_emulator_smoke.py +++ /dev/null @@ -1,176 +0,0 @@ -import argparse -from pathlib import Path -import shlex -import subprocess -import tempfile -import unittest -from unittest import mock - -import emulator_smoke as smoke - - -PACKAGE = "org.fossify.testapp" -MANIFEST = f""" - - - - - - - - - -""" - - -class RunnerTests(unittest.TestCase): - def run_checks(self, packages="", third_party="", query_error=False, log="", foreground=True): - calls = [] - - def respond(args, **kwargs): - self.assertEqual(args[:3], ["adb", "-s", "emulator-5554"]) - cmd = tuple(shlex.split(args[4]) if args[3] == "shell" else args[3:]) - calls.append(cmd) - code, output = 0, "" - if cmd == ("getprop", "ro.boot.qemu"): - output = "1\n" - elif cmd == ("getprop", "ro.build.version.sdk"): - output = "29\n" - elif cmd == ("pm", "path", PACKAGE): - if f"package:{PACKAGE}" in packages.splitlines(): - output = "package:/data/app/base.apk\n" - else: - code = 1 - elif cmd == ("pm", "list", "packages", "--user", "0"): - code, output = (1 if query_error else 0), packages - elif cmd == ("pm", "list", "packages", "-3"): - output = third_party - elif cmd[0] == "install": - output = "Success\n" - elif cmd[:2] == ("am", "start"): - output = "Status: ok\n" - elif cmd[:1] == ("logcat",): - output = log - elif cmd == ("dumpsys", "activity", "activities"): - resumed = PACKAGE if foreground else "com.android.permissioncontroller" - output = f"mResumedActivity: ActivityRecord{{123 u0 {resumed}/.MainActivity t1}}" - return subprocess.CompletedProcess(args, code, output, "") - - with tempfile.TemporaryDirectory() as directory: - apk = Path(directory) / "app.apk" - apk.write_bytes(b"Test input; manifest parsing is mocked") - args = argparse.Namespace(apk=apk, serial="emulator-5554", api=29, - output=Path(directory) / "results", observe_seconds=0) - with mock.patch.object(smoke, "read_apk", return_value=smoke.parse_manifest(MANIFEST)), \ - mock.patch.object(smoke.subprocess, "run", side_effect=respond), \ - mock.patch.object(smoke.time, "sleep"), \ - mock.patch.object(smoke, "write_report") as report: - status = smoke.run_checks(args) - return status, report.call_args.args[1], calls - - def test_fresh_package_reaches_install_and_launch(self): - status, report, calls = self.run_checks(packages="package:android\n") - self.assertEqual(status, 0, report["errors"]) - self.assertTrue(any(cmd[0] == "install" for cmd in calls)) - self.assertTrue(any(cmd[:2] == ("am", "start") for cmd in calls)) - self.assertNotIn(("pm", "path", PACKAGE), calls) - - def test_existing_package_is_not_replaced(self): - status, report, calls = self.run_checks(packages=f"package:{PACKAGE}\n") - self.assertEqual(status, 1) - self.assertIn("already installed", report["errors"][0]) - self.assertFalse(any(cmd[0] == "install" for cmd in calls)) - - def test_package_name_prefix_is_not_an_existing_install(self): - status, report, _ = self.run_checks(packages=f"package:{PACKAGE}.system\n") - self.assertEqual(status, 0, report["errors"]) - - def test_failed_package_query_stops_before_install(self): - status, report, calls = self.run_checks(query_error=True) - self.assertEqual(status, 1) - self.assertIn("pm list packages --user 0", report["errors"][0]) - self.assertFalse(any(cmd[0] == "install" for cmd in calls)) - - def test_other_user_installed_apps_are_refused(self): - status, report, calls = self.run_checks(third_party="package:org.example.personal\n") - self.assertEqual(status, 1) - self.assertIn("user-installed apps", report["errors"][0]) - self.assertFalse(any(cmd[0] == "install" for cmd in calls)) - - def test_launcher_crash_fails(self): - status, report, _ = self.run_checks(log=f"FATAL EXCEPTION: main\nProcess: {PACKAGE}, PID: 123\n") - self.assertEqual(status, 1) - self.assertEqual(report["activities"][0]["result"], "crash or ANR") - - def test_system_role_dialog_is_not_an_app_launch(self): - status, report, _ = self.run_checks(foreground=False) - self.assertEqual(status, 1) - self.assertEqual(report["activities"][0]["result"], "launcher did not reach app") - - -class RoleTests(unittest.TestCase): - def test_home_activity_and_launcher_alias_are_detected(self): - manifest = MANIFEST.replace("android.intent.category.LAUNCHER", "android.intent.category.HOME") - manifest = manifest.replace("", """ - - - - - - - """) - plan = smoke.parse_manifest(manifest) - self.assertIn("HOME", plan["roles"]) - self.assertEqual(plan["home_activity"], PACKAGE + ".MainActivity") - - def test_android_10_and_12_use_supported_role_commands(self): - for api in (29, 31): - with self.subTest(api=api): - emulator = smoke.Emulator("emulator-5554") - - def shell(*cmd, **kwargs): - if cmd[:3] != ("cmd", "role", "add-role-holder"): - raise smoke.SmokeError("Unsupported role command") - return "" - - with mock.patch.object(emulator, "shell", side_effect=shell) as run: - emulator.set_roles(["SMS", "DIALER", "HOME"], PACKAGE, api) - self.assertEqual(run.call_args_list, [ - mock.call("cmd", "role", "add-role-holder", "--user", "0", - "android.app.role." + role, PACKAGE) - for role in ("SMS", "DIALER", "HOME") - ]) - - def test_role_assignment_failure_is_not_ignored(self): - emulator = smoke.Emulator("emulator-5554") - with mock.patch.object(smoke.subprocess, "run", return_value=subprocess.CompletedProcess( - [], 1, "", "Role assignment failed")): - with self.assertRaises(smoke.SmokeError): - emulator.set_roles(["SMS"], PACKAGE, 29) - - def test_android_8_uses_standalone_telecom(self): - emulator = smoke.Emulator("emulator-5554") - - def shell(*cmd, **kwargs): - if cmd[0] != "telecom": - raise smoke.SmokeError("Unsupported telecom command") - return PACKAGE if cmd[1] == "get-default-dialer" else "Success" - - with mock.patch.object(emulator, "shell", side_effect=shell) as run: - emulator.set_roles(["DIALER"], PACKAGE, 26) - self.assertEqual(run.call_args_list, [ - mock.call("telecom", "set-default-dialer", PACKAGE), - mock.call("telecom", "get-default-dialer"), - ]) - - def test_android_8_home_uses_a_component(self): - emulator = smoke.Emulator("emulator-5554") - with mock.patch.object(emulator, "shell", return_value="Success") as run: - emulator.set_roles(["HOME"], PACKAGE, 26, PACKAGE + ".MainActivity") - run.assert_called_once_with("cmd", "package", "set-home-activity", "--user", "0", - PACKAGE + "/" + PACKAGE + ".MainActivity") - - -if __name__ == "__main__": - unittest.main() From 6803f0ee038334e3a20881882780bfc1ff48bf77 Mon Sep 17 00:00:00 2001 From: Naveen Singh <36371707+naveensingh@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:19:13 +0530 Subject: [PATCH 5/8] fix: use emulated cameras --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7b9c915..ac4ebe3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -220,7 +220,7 @@ jobs: arch: x86_64 ram-size: 2048M cores: 2 - emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none -camera-front none + 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() From eef48f1a32551f3e74328456106198b23ecf08c0 Mon Sep 17 00:00:00 2001 From: Naveen Singh <36371707+naveensingh@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:30:54 +0530 Subject: [PATCH 6/8] fix: don't be too cautious --- scripts/emulator_smoke.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/emulator_smoke.py b/scripts/emulator_smoke.py index 35daf09..e767756 100644 --- a/scripts/emulator_smoke.py +++ b/scripts/emulator_smoke.py @@ -232,8 +232,6 @@ def run_checks(args): 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") - if emulator.shell("pm", "list", "packages", "-3").strip(): - raise SmokeError("Emulator has user-installed apps; use a fresh one") emulator.adb("logcat", "-c") installed = emulator.adb("install", "-g", str(args.apk.resolve()), timeout=120) if "Success" not in installed: From a00e2d965397d49b95c0addb021d32aa1c6af617 Mon Sep 17 00:00:00 2001 From: Naveen Singh <36371707+naveensingh@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:32:03 +0530 Subject: [PATCH 7/8] ci: update android emulator action --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ac4ebe3..811a93f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -213,7 +213,7 @@ jobs: fi "$sdk_tools/latest/bin/sdkmanager" --version - name: Launch release activities - uses: reactivecircus/android-emulator-runner@1dcd0090116d15e7c562f8db72807de5e036a4ed + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d with: api-level: ${{ matrix.image }} target: google_apis From d181084b40b0155c5568d319cbcb1f4296e48230 Mon Sep 17 00:00:00 2001 From: Naveen Singh <36371707+naveensingh@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:33:13 +0530 Subject: [PATCH 8/8] ci: remove shared_ref input --- .github/workflows/pr.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 811a93f..96aae7c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -18,11 +18,6 @@ on: type: string default: ":app:testFossDebugUnitTest" required: false - shared_ref: - description: "Shared script revision; match the workflow revision when using a branch, tag or commit." - type: string - default: "main" - required: false concurrency: group: pr-ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -134,7 +129,6 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: repository: FossifyOrg/.github - ref: ${{ inputs.shared_ref }} path: .github-repo persist-credentials: false - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6