From 69595f1cbc3f07521d8d6e07a2f90eb420c4d728 Mon Sep 17 00:00:00 2001 From: shakib Date: Thu, 12 Mar 2026 14:55:28 +0600 Subject: [PATCH 1/2] Enhance Android emulator management with environment setup for mac and logging improvements --- Framework/install_handler/android/emulator.py | 171 ++++++++++++++---- 1 file changed, 135 insertions(+), 36 deletions(-) diff --git a/Framework/install_handler/android/emulator.py b/Framework/install_handler/android/emulator.py index 690612e7..f47d9910 100644 --- a/Framework/install_handler/android/emulator.py +++ b/Framework/install_handler/android/emulator.py @@ -4,6 +4,7 @@ import asyncio import re import random +import tempfile from pathlib import Path from settings import ZEUZ_NODE_DOWNLOADS_DIR from Framework.install_handler.utils import send_response, debug @@ -54,6 +55,62 @@ def _is_darwin(): return platform.system() == 'Darwin' +def _build_android_process_env(sdk_root: Path | None = None) -> dict[str, str]: + """ + Build subprocess env that always points to ZeuZ-managed Android SDK. + This keeps sdkmanager/avdmanager/emulator in the same SDK context. + """ + sdk_root = sdk_root or _get_sdk_root() + env = os.environ.copy() + sdk_root_str = str(sdk_root) + env["ANDROID_HOME"] = sdk_root_str + env["ANDROID_SDK_ROOT"] = sdk_root_str + + # Prepend SDK paths to make sure ZeuZ binaries are resolved first. + sdk_paths = [ + str(sdk_root / "platform-tools"), + str(sdk_root / "emulator"), + str(sdk_root / "cmdline-tools" / "latest" / "bin"), + ] + current_path = env.get("PATH", "") + env["PATH"] = os.pathsep.join([*sdk_paths, current_path]) if current_path else os.pathsep.join(sdk_paths) + return env + + +def _read_file_tail(path: Path, max_lines: int = 20) -> str: + """Read tail of a log file for error hints.""" + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + lines = f.readlines() + tail = [line.strip() for line in lines[-max_lines:] if line.strip()] + return " | ".join(tail) + except Exception: + return "" + + +def _get_host_arch() -> str: + """Normalize host architecture for image selection.""" + arch = platform.machine().lower() + if arch in {"arm64", "aarch64"}: + return "arm64" + if arch in {"x86_64", "amd64", "x64"}: + return "x86_64" + return arch + + +def _get_arch_preference_order() -> list[str]: + """ + Return preferred system-image ABI order for current host. + On Apple Silicon, arm64-v8a must be preferred over x86_64. + """ + host_arch = _get_host_arch() + if _is_darwin() and host_arch == "arm64": + return ["arm64-v8a", "arm64", "aarch64", "x86_64", "x86"] + if host_arch == "x86_64": + return ["x86_64", "x86", "arm64-v8a", "arm64", "aarch64"] + return [host_arch, "x86_64", "x86", "arm64-v8a", "arm64", "aarch64"] + + def get_emulator_command(): """ Returns the correct emulator executable path depending on OS. @@ -101,13 +158,15 @@ async def get_available_avds() -> list[dict]: # Run avdmanager list avd command using async executor loop = asyncio.get_event_loop() + env = _build_android_process_env(sdk_root) result = await loop.run_in_executor( None, lambda: subprocess.run( - [str(avdmanager), "list", "avd"], + [str(avdmanager), f"--sdk_root={sdk_root}", "list", "avd"], capture_output=True, text=True, - timeout=30 + timeout=30, + env=env ) ) @@ -197,16 +256,43 @@ async def launch_avd(avd_name: str) -> bool: Sends response to server on success or failure. """ try: + sdk_root = _get_sdk_root() emulator_path = get_emulator_command() + env = _build_android_process_env(sdk_root) + sanitized_name = re.sub(r"[^a-zA-Z0-9._-]", "_", avd_name) or "avd" + log_dir = Path(tempfile.gettempdir()) / "zeuz" / "android_emulator_logs" + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / f"{sanitized_name}.log" + + # Launch emulator in background and validate it does not exit immediately. + with open(log_path, "w", encoding="utf-8") as log_file: + process = subprocess.Popen( + [emulator_path, "-avd", avd_name, "-sdk-root", str(sdk_root)], + stdout=log_file, + stderr=subprocess.STDOUT, + start_new_session=True, # Detach from parent process + env=env + ) - # Launch emulator in background using Popen (non-blocking) - # Popen returns immediately, so we can call it directly without blocking - process = subprocess.Popen( - [emulator_path, "-avd", avd_name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True # Detach from parent process - ) + # Give emulator time to fail fast (common for missing/invalid system image). + await asyncio.sleep(3) + returncode = process.poll() + if returncode is not None: + launch_hint = _read_file_tail(log_path) + error_msg = f"Emulator process for {avd_name} exited immediately (code {returncode})." + if launch_hint: + error_msg += f" Output hint: {launch_hint[:500]}" + print(f"[installer][emulator] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "name": avd_name, + "status": "not installed", + "comment": error_msg, + } + }) + return False print(f"[installer][emulator] Launching AVD: {avd_name}... (PID: {process.pid})") @@ -578,13 +664,15 @@ async def get_available_devices() -> list[dict]: # Run avdmanager list device using async executor loop = asyncio.get_event_loop() + env = _build_android_process_env(sdk_root) result = await loop.run_in_executor( None, lambda: subprocess.run( - [str(avdmanager), "list", "device"], + [str(avdmanager), f"--sdk_root={sdk_root}", "list", "device"], capture_output=True, text=True, - timeout=60 + timeout=60, + env=env ) ) @@ -761,10 +849,11 @@ def _get_existing_avd_names() -> list[str]: return [] result = subprocess.run( - [str(avdmanager), "list", "avd"], + [str(avdmanager), f"--sdk_root={sdk_root}", "list", "avd"], capture_output=True, text=True, - timeout=30 + timeout=30, + env=_build_android_process_env(sdk_root) ) if result.returncode != 0: @@ -1108,13 +1197,15 @@ def _run_avdmanager_create_windows(avdmanager: Path, sdk_root: Path, avd_name: s try: # Create AVD: avdmanager create avd -n {avd_name} -k {system_image} -d {device_id} # Answer "no" to custom hardware profile prompt + env = _build_android_process_env(sdk_root) process = subprocess.Popen( - [str(avdmanager), "create", "avd", "-n", avd_name, "-k", system_image, "-d", device_id], + [str(avdmanager), f"--sdk_root={sdk_root}", "create", "avd", "-n", avd_name, "-k", system_image, "-d", device_id], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, - bufsize=1 # Line buffered + bufsize=1, # Line buffered + env=env ) # Send "no" to custom hardware profile prompt @@ -1169,13 +1260,15 @@ def _run_avdmanager_create_linux(avdmanager: Path, sdk_root: Path, avd_name: str try: # Create AVD: avdmanager create avd -n {avd_name} -k {system_image} -d {device_id} # Answer "no" to custom hardware profile prompt + env = _build_android_process_env(sdk_root) process = subprocess.Popen( - [str(avdmanager), "create", "avd", "-n", avd_name, "-k", system_image, "-d", device_id], + [str(avdmanager), f"--sdk_root={sdk_root}", "create", "avd", "-n", avd_name, "-k", system_image, "-d", device_id], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, - bufsize=1 # Line buffered + bufsize=1, # Line buffered + env=env ) # Send "no" to custom hardware profile prompt @@ -1230,13 +1323,15 @@ def _run_avdmanager_create_darwin(avdmanager: Path, sdk_root: Path, avd_name: st try: # Create AVD: avdmanager create avd -n {avd_name} -k {system_image} -d {device_id} # Answer "no" to custom hardware profile prompt + env = _build_android_process_env(sdk_root) process = subprocess.Popen( - [str(avdmanager), "create", "avd", "-n", avd_name, "-k", system_image, "-d", device_id], + [str(avdmanager), f"--sdk_root={sdk_root}", "create", "avd", "-n", avd_name, "-k", system_image, "-d", device_id], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, - bufsize=1 # Line buffered + bufsize=1, # Line buffered + env=env ) # Send "no" to custom hardware profile prompt @@ -1411,7 +1506,8 @@ def _get_highest_api_system_image(system_images: list[dict]) -> str | None: if not system_images: return None - # Extract API levels - API level is the priority + # Extract API levels - API level is the priority. + arch_preference = _get_arch_preference_order() candidates = [] for img in system_images: package = img.get("package", "") @@ -1433,30 +1529,33 @@ def _get_highest_api_system_image(system_images: list[dict]) -> str | None: variant = parts[2] if len(parts) > 2 else "" arch = parts[3] if len(parts) > 3 else "" - # Variant/arch preference for tiebreaking (only used when API levels are equal) + # Variant preference for tiebreaking (only used when API levels are equal). variant_priority = 0 - if variant == "google_apis" and arch == "x86_64": - variant_priority = 3 # Best variant/arch combo - elif variant == "google_apis_playstore" and arch == "x86_64": - variant_priority = 2 # Second best - elif variant == "google_apis": - variant_priority = 1 + if variant == "google_apis": + variant_priority = 3 elif variant == "google_apis_playstore": + variant_priority = 2 + elif variant: variant_priority = 1 + + # Host-compatible architecture preference (important for Apple Silicon). + if arch in arch_preference: + arch_priority = len(arch_preference) - arch_preference.index(arch) else: - variant_priority = 0 + arch_priority = 0 candidates.append({ "package": package, "api_level": api_level, + "arch_priority": arch_priority, "variant_priority": variant_priority }) if not candidates: return None - # Sort by API level (descending - highest first), then by variant priority (tiebreaker) - candidates.sort(key=lambda x: (x["api_level"], x["variant_priority"]), reverse=True) + # Sort by API level (descending), then host arch compatibility, then image variant. + candidates.sort(key=lambda x: (x["api_level"], x["arch_priority"], x["variant_priority"]), reverse=True) # Return the highest API level (variant priority only matters if API levels are equal) return candidates[0]["package"] @@ -1546,15 +1645,15 @@ async def create_avd_from_system_image(device_param: str) -> bool: }) return False - # Step 0: Get available system images and select highest API level - print(f"[installer][emulator] Getting available system images with Android Version 16") + # Step 0: Get available system images and select highest API level. + print("[installer][emulator] Discovering the latest compatible Android system image") await send_response({ "action": "status", "data": { "category": "AndroidEmulator", "package": device_id, "status": "installing", - "comment": "Finding system image with Android Version 16", + "comment": "Finding the latest compatible Android system image", } }) @@ -1573,7 +1672,7 @@ async def create_avd_from_system_image(device_param: str) -> bool: }) return False - # Get highest API level system image (prefer google_apis;x86_64) + # Get highest API level system image with host-architecture-aware tiebreakers. system_image_name = _get_highest_api_system_image(system_images) if not system_image_name: error_msg = "Could not find a suitable system image." @@ -1639,7 +1738,7 @@ async def create_avd_from_system_image(device_param: str) -> bool: ) if not success: - error_msg = f"Failed to install Android Version 16: {output}" + error_msg = f"Failed to install system image '{system_image_name}': {output}" print(f"[installer][emulator] {error_msg}") await send_response({ "action": "status", From c6ca7d618514846a7a3c45af2230cc33fe1e0fc7 Mon Sep 17 00:00:00 2001 From: shakib Date: Thu, 12 Mar 2026 15:28:29 +0600 Subject: [PATCH 2/2] AVD manager with macOS compatibility and subprocess handling --- Framework/install_handler/android/emulator.py | 231 +++++++++++------- 1 file changed, 144 insertions(+), 87 deletions(-) diff --git a/Framework/install_handler/android/emulator.py b/Framework/install_handler/android/emulator.py index f47d9910..1320453f 100644 --- a/Framework/install_handler/android/emulator.py +++ b/Framework/install_handler/android/emulator.py @@ -77,6 +77,42 @@ def _build_android_process_env(sdk_root: Path | None = None) -> dict[str, str]: return env +def _run_avdmanager_capture( + avdmanager: Path, + sdk_root: Path, + args: list[str], + timeout: int +) -> subprocess.CompletedProcess: + """ + Run avdmanager with SDK-root first, with a Darwin-only fallback that drops + --sdk_root but keeps the ZeuZ SDK env vars. + """ + env = _build_android_process_env(sdk_root) + cmd_with_sdk_root = [str(avdmanager), f"--sdk_root={sdk_root}", *args] + result = subprocess.run( + cmd_with_sdk_root, + capture_output=True, + text=True, + timeout=timeout, + env=env + ) + if result.returncode == 0 or not _is_darwin(): + return result + + if debug: + print(f"[installer][emulator] avdmanager command failed with --sdk_root, retrying without it on macOS. stderr: {result.stderr}") + + cmd_without_sdk_root = [str(avdmanager), *args] + fallback_result = subprocess.run( + cmd_without_sdk_root, + capture_output=True, + text=True, + timeout=timeout, + env=env + ) + return fallback_result + + def _read_file_tail(path: Path, max_lines: int = 20) -> str: """Read tail of a log file for error hints.""" try: @@ -158,16 +194,9 @@ async def get_available_avds() -> list[dict]: # Run avdmanager list avd command using async executor loop = asyncio.get_event_loop() - env = _build_android_process_env(sdk_root) result = await loop.run_in_executor( None, - lambda: subprocess.run( - [str(avdmanager), f"--sdk_root={sdk_root}", "list", "avd"], - capture_output=True, - text=True, - timeout=30, - env=env - ) + lambda: _run_avdmanager_capture(avdmanager, sdk_root, ["list", "avd"], 30) ) if result.returncode != 0: @@ -264,19 +293,29 @@ async def launch_avd(avd_name: str) -> bool: log_dir.mkdir(parents=True, exist_ok=True) log_path = log_dir / f"{sanitized_name}.log" - # Launch emulator in background and validate it does not exit immediately. - with open(log_path, "w", encoding="utf-8") as log_file: - process = subprocess.Popen( - [emulator_path, "-avd", avd_name, "-sdk-root", str(sdk_root)], - stdout=log_file, - stderr=subprocess.STDOUT, - start_new_session=True, # Detach from parent process - env=env - ) - - # Give emulator time to fail fast (common for missing/invalid system image). + def _spawn_emulator(cmd: list[str]) -> subprocess.Popen: + with open(log_path, "w", encoding="utf-8") as log_file: + return subprocess.Popen( + cmd, + stdout=log_file, + stderr=subprocess.STDOUT, + start_new_session=True, # Detach from parent process + env=env + ) + + # Try launch with explicit sdk-root first. + process = _spawn_emulator([emulator_path, "-avd", avd_name, "-sdk-root", str(sdk_root)]) await asyncio.sleep(3) returncode = process.poll() + + # macOS compatibility: retry without -sdk-root if first launch exits immediately. + if returncode is not None and _is_darwin(): + if debug: + print("[installer][emulator] Emulator exited quickly with -sdk-root on macOS. Retrying without -sdk-root.") + process = _spawn_emulator([emulator_path, "-avd", avd_name]) + await asyncio.sleep(3) + returncode = process.poll() + if returncode is not None: launch_hint = _read_file_tail(log_path) error_msg = f"Emulator process for {avd_name} exited immediately (code {returncode})." @@ -664,16 +703,9 @@ async def get_available_devices() -> list[dict]: # Run avdmanager list device using async executor loop = asyncio.get_event_loop() - env = _build_android_process_env(sdk_root) result = await loop.run_in_executor( None, - lambda: subprocess.run( - [str(avdmanager), f"--sdk_root={sdk_root}", "list", "device"], - capture_output=True, - text=True, - timeout=60, - env=env - ) + lambda: _run_avdmanager_capture(avdmanager, sdk_root, ["list", "device"], 60) ) if result.returncode != 0: @@ -848,13 +880,7 @@ def _get_existing_avd_names() -> list[str]: if not avdmanager: return [] - result = subprocess.run( - [str(avdmanager), f"--sdk_root={sdk_root}", "list", "avd"], - capture_output=True, - text=True, - timeout=30, - env=_build_android_process_env(sdk_root) - ) + result = _run_avdmanager_capture(avdmanager, sdk_root, ["list", "avd"], 30) if result.returncode != 0: return [] @@ -1321,60 +1347,91 @@ def _run_avdmanager_create_linux(avdmanager: Path, sdk_root: Path, avd_name: str def _run_avdmanager_create_darwin(avdmanager: Path, sdk_root: Path, avd_name: str, system_image: str, device_id: str) -> tuple[bool, str]: """Create AVD on macOS with real-time output""" try: - # Create AVD: avdmanager create avd -n {avd_name} -k {system_image} -d {device_id} - # Answer "no" to custom hardware profile prompt env = _build_android_process_env(sdk_root) - process = subprocess.Popen( - [str(avdmanager), f"--sdk_root={sdk_root}", "create", "avd", "-n", avd_name, "-k", system_image, "-d", device_id], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, # Line buffered - env=env - ) - - # Send "no" to custom hardware profile prompt - process.stdin.write("no\n") - process.stdin.close() - - # Print output in real-time as it comes, showing progress on single line - output_lines = [] - last_progress = "" - try: - for line in iter(process.stdout.readline, ''): - if line: - stripped = line.strip() - output_lines.append(stripped) - - # Extract progress percentage from lines like "[====] 25% Loading..." - progress_match = re.search(r'\[.*?\]\s*(\d+)%\s*(.+)', stripped) - if progress_match: - percent = progress_match.group(1) - status = progress_match.group(2).strip() - current_progress = f"{percent}% {status}" - if current_progress != last_progress: - print(f"\r[installer][emulator] Download progress: {current_progress}", end='', flush=True) - last_progress = current_progress - elif stripped and not stripped.startswith('[') and '%' not in stripped: - # Print important non-progress messages on new line - print(f"\n[installer][emulator] {stripped}") - elif stripped.endswith('%'): - # Handle lines that end with just percentage - print(f"\r[installer][emulator] Download progress: {stripped}", end='', flush=True) - except Exception as e: - print(f"\n[installer][emulator] Output reading error: {e}") - finally: - print() # New line after progress completes - - process.stdout.close() - returncode = process.wait(timeout=120) - - output = "\n".join(output_lines) - if returncode == 0: + + def _run_create_command(cmd: list[str]) -> tuple[bool, str]: + # Create AVD: avdmanager create avd -n {avd_name} -k {system_image} -d {device_id} + # Answer "no" to custom hardware profile prompt + process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, # Line buffered + env=env + ) + + process.stdin.write("no\n") + process.stdin.close() + + output_lines = [] + last_progress = "" + try: + for line in iter(process.stdout.readline, ''): + if line: + stripped = line.strip() + output_lines.append(stripped) + + # Extract progress percentage from lines like "[====] 25% Loading..." + progress_match = re.search(r'\[.*?\]\s*(\d+)%\s*(.+)', stripped) + if progress_match: + percent = progress_match.group(1) + status = progress_match.group(2).strip() + current_progress = f"{percent}% {status}" + if current_progress != last_progress: + print(f"\r[installer][emulator] Download progress: {current_progress}", end='', flush=True) + last_progress = current_progress + elif stripped and not stripped.startswith('[') and '%' not in stripped: + # Print important non-progress messages on new line + print(f"\n[installer][emulator] {stripped}") + elif stripped.endswith('%'): + # Handle lines that end with just percentage + print(f"\r[installer][emulator] Download progress: {stripped}", end='', flush=True) + except Exception as e: + print(f"\n[installer][emulator] Output reading error: {e}") + finally: + print() # New line after progress completes + + process.stdout.close() + returncode = process.wait(timeout=120) + output = "\n".join(output_lines) + return returncode == 0, output + + primary_cmd = [ + str(avdmanager), + f"--sdk_root={sdk_root}", + "create", + "avd", + "-n", + avd_name, + "-k", + system_image, + "-d", + device_id, + ] + success, output = _run_create_command(primary_cmd) + if success: return True, output - else: - return False, output + + # macOS-only compatibility fallback for avdmanager builds that reject --sdk_root. + if debug: + print("[installer][emulator] Retrying AVD create without --sdk_root on macOS.") + fallback_cmd = [ + str(avdmanager), + "create", + "avd", + "-n", + avd_name, + "-k", + system_image, + "-d", + device_id, + ] + fallback_success, fallback_output = _run_create_command(fallback_cmd) + if fallback_success: + return True, fallback_output + return False, fallback_output or output except subprocess.TimeoutExpired: return False, "AVD creation timed out" except Exception as e: @@ -1871,4 +1928,4 @@ async def create_avd_from_system_image(device_param: str) -> bool: "comment": error_msg, } }) - return False \ No newline at end of file + return False