import gradio as gr import subprocess import tempfile import shutil import os import glob import zipfile import hashlib from concurrent.futures import ThreadPoolExecutor # ── Пути к SDK инструментам ────────────────────────────────────────────── BUILD_TOOLS = "/opt/android-sdk/build-tools/33.0.2" JAVA_HOME = "/usr/lib/jvm/java-17-openjdk-amd64" # Kotlin-компилятор. Компилирует .kt (и вместе с .java — совместно) в JVM # bytecode (.class). Дальше d8 уже не отличает, откуда взялся класс. KOTLINC = "/opt/kotlinc/bin/kotlinc" # kotlin-stdlib нужен в classpath на этапе компиляции и в --lib на этапе d8, # иначе рантайм не найдёт kotlin.jvm.internal.Intrinsics и т.п. KOTLIN_STDLIB_JAR = "/opt/kotlinc/lib/kotlin-stdlib.jar" # Путь к предустановленным стабам DEFAULT_STUBS_DIR = "/opt/default-stubs" # Постоянный кэш очищенных стаб-JAR (переживает запуски компиляции). STUB_CACHE_DIR = os.path.join(tempfile.gettempdir(), "stub_clean_cache") os.makedirs(STUB_CACHE_DIR, exist_ok=True) # Выбираем android.jar (приоритет отдается скачанному, иначе SDK-версия) if os.path.exists(os.path.join(DEFAULT_STUBS_DIR, "android.jar")): ANDROID_JAR = os.path.join(DEFAULT_STUBS_DIR, "android.jar") else: ANDROID_JAR = "/opt/android-sdk/platforms/android-33/android.jar" # Пакеты, конфликтующие с JDK bootclasspath / android.jar — # вырезаются из стаб-jar приложения CONFLICTING_PREFIXES = ( "java/", "javax/", "jdk/", "org/jetbrains/annotations/", "org/intellij/", # Стаб-JAR-ы (например app_stubs.jar) иногда сами содержат Kotlin-рантайм, # часто более новой/несовместимой версии metadata, чем наш kotlinc. # Наш собственный kotlin-stdlib.jar уже добавляется в classpath отдельно, # поэтому дублирующийся kotlin.* из стабов только ломает компиляцию. "kotlin/", "kotlinx/", ) EXAMPLE_CODE = """\ package com.example.hook import de.robv.android.xposed.XC_MethodHook import de.robv.android.xposed.XposedBridge import de.robv.android.xposed.callbacks.XC_LoadPackage import android.content.Context class MainHook : IXposedHookLoadPackage { override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam) { if (lpparam.packageName != "com.target.app") return XposedBridge.hookAllMethods( lpparam.classLoader.loadClass("com.target.app.SomeClass"), "targetMethod", object : XC_MethodHook() { override fun afterHookedMethod(param: MethodHookParam) { val ctx = param.thisObject as Context // ... ваша логика ... } } ) } } """ # --------------------------------------------------------------------------- # Sanitize stub JAR — удаляем конфликтующие с JDK/android.jar пакеты # --------------------------------------------------------------------------- def sanitize_stub_jar(src_jar: str, out_dir: str, log_lines: list, use_cache: bool = False) -> str: """ Копирует src_jar в новый .jar, выбрасывая все entries, чьи пути начинаются с CONFLICTING_PREFIXES. Возвращает путь к очищенному jar. """ basename = os.path.basename(src_jar) stem = os.path.splitext(basename)[0] if use_cache: try: st = os.stat(src_jar) key = hashlib.md5( f"{src_jar}:{st.st_mtime_ns}:{st.st_size}".encode() ).hexdigest()[:12] cached = os.path.join(STUB_CACHE_DIR, f"{stem}_{key}.jar") if os.path.exists(cached): log_lines.append(f"♻️ Reused cached clean JAR: {basename}") return cached out_jar = cached except OSError: use_cache = False out_jar = os.path.join(out_dir, stem + "_clean.jar") else: out_jar = os.path.join(out_dir, stem + "_clean.jar") removed = [] kept = 0 tmp_out = out_jar + ".tmp" with zipfile.ZipFile(src_jar, "r") as src, \ zipfile.ZipFile(tmp_out, "w", zipfile.ZIP_STORED) as dst: for entry in src.infolist(): name = entry.filename if any(name.startswith(p) for p in CONFLICTING_PREFIXES): removed.append(name) else: dst.writestr(entry, src.read(name)) kept += 1 os.replace(tmp_out, out_jar) if removed: log_lines.append(f"🧹 Sanitized — removed {len(removed)} conflicting entries, kept {kept}") bad_pkgs = sorted({e.split("/")[0] + "/" for e in removed}) log_lines.append(f" Removed top-level packages: {', '.join(bad_pkgs)}") else: log_lines.append(f"✅ JAR is clean — no conflicting packages found ({kept} entries)") return out_jar def sanitize_all(all_stubs: list, auto_stubs: list, workdir: str, log_lines: list) -> list: """Санитизирует список стаб-JAR параллельно. Порядок сохраняется.""" auto_set = set(auto_stubs) def work(stub_path): sub: list = [] is_default = stub_path in auto_set label = " [Pre-installed]" if is_default else "" sub.append(f"📦 {os.path.basename(stub_path)}{label}") clean = sanitize_stub_jar(stub_path, workdir, sub, use_cache=is_default) return clean, sub clean_stubs: list = [] if not all_stubs: return clean_stubs with ThreadPoolExecutor(max_workers=min(8, len(all_stubs))) as ex: for clean, sub in ex.map(work, all_stubs): clean_stubs.append(clean) log_lines.extend(sub) return clean_stubs # --------------------------------------------------------------------------- # Извлечение исходников из загруженных файлов (.java / .kt / .zip) # --------------------------------------------------------------------------- def extract_sources(upload_paths: list[str], src_dir: str, log_lines: list) -> tuple[list[str], list[str]]: """ Принимает список файлов (.java, .kt, .zip). Распаковывает ZIP-архивы, копирует .java/.kt файлы в src_dir. Возвращает (java_files, kotlin_files). """ for upload_path in upload_paths: ext = os.path.splitext(upload_path)[1].lower() basename = os.path.basename(upload_path) if ext == ".zip": with zipfile.ZipFile(upload_path, "r") as zf: zf.extractall(src_dir) log_lines.append(f"📦 ZIP extracted: {basename}") elif ext in (".java", ".kt"): dest = os.path.join(src_dir, basename) shutil.copy(upload_path, dest) log_lines.append(f"📄 Source copied: {basename}") else: log_lines.append(f"⚠️ Unknown extension '{ext}' for {basename} — treating as .kt") dest = os.path.join(src_dir, basename + ".kt") shutil.copy(upload_path, dest) java_files = [] kotlin_files = [] for root, _, files in os.walk(src_dir): for f in files: if f.endswith(".java"): java_files.append(os.path.join(root, f)) elif f.endswith(".kt"): kotlin_files.append(os.path.join(root, f)) log_lines.append( f"🔍 Found {len(java_files)} .java file(s) and {len(kotlin_files)} .kt file(s)" ) return java_files, kotlin_files def pack_classes_jar(classes_dir: str, jar_path: str) -> None: with zipfile.ZipFile(jar_path, "w", zipfile.ZIP_DEFLATED) as zf: for root, _, files in os.walk(classes_dir): for filename in files: if filename.endswith(".class"): path = os.path.join(root, filename) zf.write(path, os.path.relpath(path, classes_dir)) def merge_classpath_jars(jar_paths: list[str], out_path: str, log_lines: list[str]) -> str | None: """Объединяет classpath JAR-ы в один, удаляя дубли между стабами.""" if not jar_paths: return None seen: set[str] = set() duplicate_classes = 0 written = 0 with zipfile.ZipFile(out_path, "w", zipfile.ZIP_STORED) as dst: for jar_path in jar_paths: with zipfile.ZipFile(jar_path, "r") as src: for entry in src.infolist(): name = entry.filename if entry.is_dir(): continue if name.startswith("META-INF/versions/"): continue if name in seen: if name.endswith(".class"): duplicate_classes += 1 continue seen.add(name) upper_name = name.upper() if upper_name.startswith("META-INF/") and upper_name.endswith((".SF", ".RSA", ".DSA")): continue dst.writestr(entry, src.read(name)) written += 1 log_lines.append( f"🧩 Classpath merged: {len(jar_paths)} JAR(s), " f"{written} entries, removed {duplicate_classes} duplicate class(es)" ) return out_path # --------------------------------------------------------------------------- # Основная функция компиляции (Java/Kotlin -> DEX / Class) # --------------------------------------------------------------------------- def compile_to_dex( code: str, filename: str, source_files: list[str], stub_jar_paths: list[str], ) -> tuple[str, str | None, str | None]: workdir = tempfile.mkdtemp(prefix="kt_dex_") log_lines: list[str] = [] try: src_dir = os.path.join(workdir, "src") classes_dir = os.path.join(workdir, "classes") dex_dir = os.path.join(workdir, "dex_out") os.makedirs(src_dir) os.makedirs(classes_dir) os.makedirs(dex_dir) base_name = os.path.splitext(os.path.basename(filename or "Main"))[0] or "Main" # ── Сбор исходников ── if source_files: log_lines.append(f"📂 Source files ({len(source_files)}):") for sf in source_files: log_lines.append(f" • {os.path.basename(sf)}") java_files, kotlin_files = extract_sources(source_files, src_dir, log_lines) else: src_path = os.path.join(src_dir, f"{base_name}.kt") with open(src_path, "w", encoding="utf-8") as f: f.write(code) java_files, kotlin_files = [], [src_path] log_lines.append(f"📝 Source saved from editor: {base_name}.kt") all_source_files = java_files + kotlin_files if not all_source_files: log_lines.append("❌ No .java/.kt files found — aborting") return "\n".join(log_lines), None, None # ── Сбор и подготовка стаб-файлов ── auto_stubs = [] if os.path.isdir(DEFAULT_STUBS_DIR): for f in sorted(os.listdir(DEFAULT_STUBS_DIR)): if f.endswith(".jar") and f != "android.jar": auto_stubs.append(os.path.join(DEFAULT_STUBS_DIR, f)) valid_stubs = [p for p in stub_jar_paths if p and os.path.isfile(p)] all_stubs = auto_stubs + valid_stubs if all_stubs: log_lines.append("") log_lines.append(f"── Sanitizing {len(all_stubs)} stub JAR(s) (parallel + cached) ──") clean_stubs = sanitize_all(all_stubs, auto_stubs, workdir, log_lines) else: clean_stubs = [] log_lines.append("⚠️ No stub JARs provided — compiling without extra classpath entries") log_lines.append("") classpath_parts = [ANDROID_JAR] + clean_stubs if kotlin_files: classpath_parts.append(KOTLIN_STDLIB_JAR) classpath = ":".join(classpath_parts) # ── Step 1: компиляция в JVM bytecode (.class) ── log_lines.append("═" * 50) log_lines.append("STEP 1 — Compiling source → JVM bytecode (.class)") log_lines.append("═" * 50) if kotlin_files: # kotlinc умеет компилировать .kt и .java совместно за один вызов # (это важно, если Kotlin-код ссылается на Java-классы и наоборот). kotlinc_cmd = [ KOTLINC, "-cp", classpath, "-d", classes_dir, "-jvm-target", "1.8", "-Xno-call-assertions", # чуть легче итоговый байткод "-nowarn", ] + kotlin_files + java_files log_lines.append("$ " + " ".join(kotlinc_cmd)) result = subprocess.run( kotlinc_cmd, capture_output=True, text=True, timeout=300, env={**os.environ, "JAVA_HOME": JAVA_HOME}, ) if result.stdout: log_lines.append(result.stdout) if result.stderr: log_lines.append(result.stderr) if result.returncode != 0: log_lines.append(f"\n❌ kotlinc failed (exit {result.returncode})") return "\n".join(log_lines), None, None else: # Только .java — используем javac напрямую (быстрее, чем гонять kotlinc) javac_cmd = [ f"{JAVA_HOME}/bin/javac", "-source", "8", "-target", "8", "-encoding", "UTF-8", "-proc:none", "-nowarn", "-d", classes_dir, "-classpath", classpath, ] + java_files log_lines.append("$ " + " ".join(javac_cmd)) result = subprocess.run( javac_cmd, capture_output=True, text=True, timeout=120, env={**os.environ, "JAVA_HOME": JAVA_HOME}, ) if result.stdout: log_lines.append(result.stdout) if result.stderr: log_lines.append(result.stderr) if result.returncode != 0: log_lines.append(f"\n❌ javac failed (exit {result.returncode})") return "\n".join(log_lines), None, None class_files = glob.glob(os.path.join(classes_dir, "**", "*.class"), recursive=True) total_size = sum(os.path.getsize(cf) for cf in class_files) log_lines.append( f"\n✅ Compilation succeeded — {len(class_files)} .class file(s) ({total_size:,} bytes total)" ) if not class_files: log_lines.append("❌ No .class files produced") return "\n".join(log_lines), None, None # Подготовка .class файлов для отдельного скачивания if len(class_files) == 1: class_out_path = os.path.join(workdir, os.path.basename(class_files[0])) shutil.copy(class_files[0], class_out_path) else: class_out_path = os.path.join(workdir, f"{base_name}_classes.zip") with zipfile.ZipFile(class_out_path, "w") as zf: for cf in class_files: rel_path = os.path.relpath(cf, classes_dir) zf.write(cf, rel_path) # ── Step 2: d8 — JVM bytecode → Dalvik (.dex) ── log_lines.append("") log_lines.append("═" * 50) log_lines.append("STEP 2 — Converting JVM bytecode → Dalvik (.dex) with d8") log_lines.append("═" * 50) d8_lib_stubs = list(clean_stubs) if kotlin_files and os.path.isfile(KOTLIN_STDLIB_JAR): # kotlin-stdlib должен присутствовать и на этапе dex-конвертации, # чтобы d8 разрешил ссылки на Intrinsics/kotlin.* классы. d8_lib_stubs.append(KOTLIN_STDLIB_JAR) build_cmd = [ f"{BUILD_TOOLS}/d8", "--release", "--min-api", "21", "--lib", ANDROID_JAR, ] for stub in d8_lib_stubs: build_cmd += ["--lib", stub] build_cmd += ["--output", dex_dir] + class_files log_lines.append("$ " + " ".join(build_cmd)) result2 = subprocess.run(build_cmd, capture_output=True, text=True, timeout=180) if result2.stdout: log_lines.append(result2.stdout) if result2.stderr: log_lines.append(result2.stderr) if result2.returncode != 0: log_lines.append(f"\n❌ d8 failed (exit {result2.returncode})") return "\n".join(log_lines), None, class_out_path dex_files = glob.glob(os.path.join(dex_dir, "*.dex")) if not dex_files: log_lines.append("❌ No .dex files produced") return "\n".join(log_lines), None, class_out_path log_lines.append(f"\n✅ d8 succeeded — {len(dex_files)} dex file(s) produced") # Упаковка результата DEX if len(dex_files) == 1: dex_out_path = os.path.join(workdir, f"{base_name}.dex") shutil.copy(dex_files[0], dex_out_path) else: dex_out_path = os.path.join(workdir, f"{base_name}_multidex.zip") with zipfile.ZipFile(dex_out_path, "w") as zf: for df in sorted(dex_files): zf.write(df, os.path.basename(df)) log_lines.append(f"\n📦 Packed into {os.path.basename(dex_out_path)}") log_lines.append("\n🎉 Compilation complete!") return "\n".join(log_lines), dex_out_path, class_out_path except Exception as e: log_lines.append(f"❌ Unexpected error: {e}") return "\n".join(log_lines), None, None # --------------------------------------------------------------------------- # Функция объединения (Fat DEX) # --------------------------------------------------------------------------- def run_fat_dex_merge(primary_inputs_raw, libs_raw, filename_base: str) -> tuple[str, str | None]: primary_inputs = normalize_file_list(primary_inputs_raw) libs = normalize_file_list(libs_raw) if not primary_inputs: return "⚠️ Please provide primary input files (.class, .jar, or .zip with classes).", None workdir = tempfile.mkdtemp(prefix="fat_dex_") log_lines: list[str] = [] try: dex_dir = os.path.join(workdir, "dex_out") os.makedirs(dex_dir) base_name = os.path.splitext(os.path.basename(filename_base or "classes"))[0] or "classes" auto_stubs = [] if os.path.isdir(DEFAULT_STUBS_DIR): for f in sorted(os.listdir(DEFAULT_STUBS_DIR)): if f.endswith(".jar") and f != "android.jar": auto_stubs.append(os.path.join(DEFAULT_STUBS_DIR, f)) # kotlin-stdlib тоже подмешиваем автоматически, если скомпилированные # классы могут ссылаться на Kotlin-рантайм. if os.path.isfile(KOTLIN_STDLIB_JAR): auto_stubs.append(KOTLIN_STDLIB_JAR) all_libs = auto_stubs + libs clean_libs = [] if all_libs: log_lines.append(f"── Sanitizing {len(all_libs)} library JAR(s) (parallel + cached) ──") clean_libs = sanitize_all(all_libs, auto_stubs, workdir, log_lines) log_lines.append("") log_lines.append("═" * 50) log_lines.append("Merging and compiling into Fat DEX") log_lines.append("═" * 50) d8_cmd = [ f"{BUILD_TOOLS}/d8", "--release", "--min-api", "21", "--lib", ANDROID_JAR, "--output", dex_dir, ] + primary_inputs + clean_libs log_lines.append("$ " + " ".join(d8_cmd)) result = subprocess.run(d8_cmd, capture_output=True, text=True, timeout=120) if result.stdout: log_lines.append(result.stdout) if result.stderr: log_lines.append(result.stderr) if result.returncode != 0: log_lines.append(f"\n❌ d8 merger failed (exit {result.returncode})") return "\n".join(log_lines), None dex_files = glob.glob(os.path.join(dex_dir, "*.dex")) if not dex_files: log_lines.append("❌ No .dex files produced") return "\n".join(log_lines), None log_lines.append(f"\n✅ Merge succeeded — {len(dex_files)} dex file(s) produced") if len(dex_files) == 1: out_path = os.path.join(workdir, f"{base_name}.dex") shutil.copy(dex_files[0], out_path) else: out_path = os.path.join(workdir, f"{base_name}_multidex.zip") with zipfile.ZipFile(out_path, "w") as zf: for df in sorted(dex_files): zf.write(df, os.path.basename(df)) log_lines.append(f"\n📦 Packed into {os.path.basename(out_path)}") log_lines.append("\n🎉 Fat DEX creation complete!") return "\n".join(log_lines), out_path except Exception as e: log_lines.append(f"❌ Unexpected error during Fat DEX merge: {e}") return "\n".join(log_lines), None # --------------------------------------------------------------------------- # Gradio handlers # --------------------------------------------------------------------------- def handle_file_upload(file_list) -> str: if not file_list: return "" for item in file_list: path = item if isinstance(item, str) else item.name ext = os.path.splitext(path)[1].lower() if ext == ".zip": return f"// ZIP archive: {os.path.basename(path)}\n// Contents will be extracted at compile time." if ext in (".java", ".kt"): try: with open(path, "r", encoding="utf-8") as f: return f.read() except Exception as e: return f"// Error reading file: {e}" return "" def normalize_file_list(raw) -> list[str]: if raw is None: return [] if isinstance(raw, str): return [raw] result = [] for item in raw: if item is None: continue result.append(item if isinstance(item, str) else item.name) return result def run_compilation( code: str, filename: str, source_files_raw, stub_jars_raw, ): source_files = normalize_file_list(source_files_raw) stub_jars = normalize_file_list(stub_jars_raw) if not source_files and (not code or not code.strip()): return "⚠️ No code provided.", None, None log, dex_path, class_path = compile_to_dex( code, filename or "Main", source_files, stub_jars, ) return log, dex_path, class_path # --------------------------------------------------------------------------- # UI # --------------------------------------------------------------------------- CSS = """ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght=400;600;700&family=Space+Grotesk:wght=400;600;700&display=swap'); :root { --bg: #0d0e14; --surface: #13141e; --border: #22253a; --accent: #3ecf8e; --accent2: #7c6af7; --text: #e2e4f0; --muted: #6b7090; } body, .gradio-container { background: var(--bg) !important; font-family: 'Space Grotesk', sans-serif !important; color: var(--text) !important; } .gr-panel, .gr-box, .gr-form { background: var(--surface) !important; border: 1px solid var(--border) !important; border-radius: 12px !important; } textarea, input[type="text"] { background: #0a0b10 !important; color: var(--text) !important; border: 1px solid var(--border) !important; font-family: 'JetBrains Mono', monospace !important; font-size: 13px !important; border-radius: 8px !important; } .gr-button-primary { background: linear-gradient(135deg, var(--accent), #2aad6f) !important; border: none !important; color: #0d0e14 !important; font-weight: 700 !important; font-size: 15px !important; border-radius: 10px !important; padding: 12px 32px !important; transition: all 0.2s !important; } .gr-button-primary:hover { transform: translateY(-1px) !important; box-shadow: 0 8px 24px rgba(62, 207, 142, 0.35) !important; } """ HEADER_HTML = """
Compile Kotlin or Java to Dalvik bytecode · SDK 33 · API 21+ · Create Fat DEX