""" WebToAPK Builder – Hugging Face Space Convierte cualquier URL en una APK de Android real e instalable. Genera APKs con bytecode DEX real, AXML binario y firma JAR (v1). """ import gradio as gr import subprocess import os import shutil import zipfile import json import re import struct import hashlib import zlib import base64 import tempfile import time from pathlib import Path from urllib.parse import urlparse # ─── Paths ──────────────────────────────────────────────────────────────────── BASE_DIR = Path(__file__).parent OUTPUT_DIR = BASE_DIR / "output" KEYS_DIR = BASE_DIR / "keys" OUTPUT_DIR.mkdir(exist_ok=True) KEYS_DIR.mkdir(exist_ok=True) # ─── Color helpers ──────────────────────────────────────────────────────────── COLORS = { "Material Blue": ("#1976D2", "#FFFFFF"), "Material Green": ("#388E3C", "#FFFFFF"), "Deep Purple": ("#512DA8", "#FFFFFF"), "Teal": ("#00796B", "#FFFFFF"), "Red": ("#D32F2F", "#FFFFFF"), "Orange": ("#F57C00", "#FFFFFF"), "Indigo": ("#303F9F", "#FFFFFF"), "Pink": ("#C2185B", "#FFFFFF"), "Brown": ("#5D4037", "#FFFFFF"), "Cyan": ("#0097A7", "#FFFFFF"), } # ───────────────────────────────────────────────────────────────────────────── # DEX BUILDER – genera classes.dex con bytecode WebView real # ───────────────────────────────────────────────────────────────────────────── def _uleb128(v): r = [] while True: b = v & 0x7F; v >>= 7 if v: b |= 0x80 r.append(b) if not v: break return bytes(r) def _string_data_item(s): enc = s.encode("utf-8") return _uleb128(len(s)) + enc + b"\x00" def _pad4(data): r = len(data) % 4 if r: data += b"\x00" * (4 - r) return data # El URL placeholder en el DEX tiene exactamente 200 caracteres (relleno con 'A') _URL_SLOT_LEN = 200 _URL_SLOT = "A" * _URL_SLOT_LEN def build_webview_dex(url: str) -> bytes: """ Construye un DEX real con una MainActivity que abre `url` en WebView. El URL se inyecta directamente en el pool de strings del DEX. """ # ── Preparar URL con padding ── if len(url) > _URL_SLOT_LEN: url = url[:_URL_SLOT_LEN] padded_url = url + " " * (_URL_SLOT_LEN - len(url)) # ── String pool (ordenado, obligatorio en DEX) ── ALL_STRINGS = sorted([ "", "()Landroid/webkit/WebSettings;", "()V", "(Landroid/content/Context;)V", "(Landroid/os/Bundle;)V", "(Landroid/view/View;)V", "(Ljava/lang/String;)V", "(Z)V", "", padded_url, # ← URL real con padding "Landroid/app/Activity;", "Landroid/content/Context;", "Landroid/os/Bundle;", "Ljava/lang/String;", "Landroid/view/View;", "Landroid/webkit/WebSettings;", "Landroid/webkit/WebView;", "Lcom/webwrapper/app/MainActivity;", "V", "Z", "canGoBack", "getSettings", "goBack", "loadUrl", "onBackPressed", "onCreate", "setContentView", "setDomStorageEnabled", "setJavaScriptEnabled", "setUseWideViewPort", ]) def si(s): return ALL_STRINGS.index(s) TYPE_LIST = [ "V", "Z", "Landroid/app/Activity;", "Landroid/content/Context;", "Landroid/os/Bundle;", "Ljava/lang/String;", "Landroid/view/View;", "Landroid/webkit/WebSettings;", "Landroid/webkit/WebView;", "Lcom/webwrapper/app/MainActivity;", ] def ti(t): return TYPE_LIST.index(t) PROTO_LIST = [ {"shorty": "()V", "ret": "V", "params": []}, {"shorty": "(Landroid/content/Context;)V", "ret": "V", "params": ["Landroid/content/Context;"]}, {"shorty": "(Landroid/os/Bundle;)V", "ret": "V", "params": ["Landroid/os/Bundle;"]}, {"shorty": "(Landroid/view/View;)V", "ret": "V", "params": ["Landroid/view/View;"]}, {"shorty": "(Ljava/lang/String;)V", "ret": "V", "params": ["Ljava/lang/String;"]}, {"shorty": "(Z)V", "ret": "V", "params": ["Z"]}, {"shorty": "()Landroid/webkit/WebSettings;", "ret": "Landroid/webkit/WebSettings;", "params": []}, ] def pi(shorty): return next(i for i,p in enumerate(PROTO_LIST) if p["shorty"]==shorty) METHOD_LIST = [ {"class": "Landroid/app/Activity;", "name": "", "proto": "()V"}, {"class": "Landroid/app/Activity;", "name": "onCreate", "proto": "(Landroid/os/Bundle;)V"}, {"class": "Landroid/app/Activity;", "name": "onBackPressed","proto": "()V"}, {"class": "Landroid/app/Activity;", "name": "setContentView","proto": "(Landroid/view/View;)V"}, {"class": "Landroid/webkit/WebView;", "name": "", "proto": "(Landroid/content/Context;)V"}, {"class": "Landroid/webkit/WebView;", "name": "getSettings", "proto": "()Landroid/webkit/WebSettings;"}, {"class": "Landroid/webkit/WebView;", "name": "loadUrl", "proto": "(Ljava/lang/String;)V"}, {"class": "Landroid/webkit/WebSettings;", "name": "setJavaScriptEnabled","proto": "(Z)V"}, {"class": "Landroid/webkit/WebSettings;", "name": "setDomStorageEnabled","proto": "(Z)V"}, {"class": "Landroid/webkit/WebSettings;", "name": "setUseWideViewPort", "proto": "(Z)V"}, {"class": "Lcom/webwrapper/app/MainActivity;", "name": "", "proto": "()V"}, {"class": "Lcom/webwrapper/app/MainActivity;", "name": "onCreate", "proto": "(Landroid/os/Bundle;)V"}, {"class": "Lcom/webwrapper/app/MainActivity;", "name": "onBackPressed","proto": "()V"}, ] def mi(cls,name,proto): return next(i for i,m in enumerate(METHOD_LIST) if m["class"]==cls and m["name"]==name and m["proto"]==proto) # ── Bytecode ── def invoke35c(op, method_idx, regs): count = len(regs) c = regs[0] if count>0 else 0 d = regs[1] if count>1 else 0 e = regs[2] if count>2 else 0 f = regs[3] if count>3 else 0 g = regs[4] if count>4 else 0 return bytes([op,(g<<4)|count, method_idx&0xFF,(method_idx>>8)&0xFF, (d<<4)|c, (f<<4)|e]) V0,V1,V2,P0,P1 = 0,1,2,3,4 def init_code(): c = invoke35c(0x70, mi("Landroid/app/Activity;","","()V"), [P0]) c += bytes([0x0e,0x00]) return _pad4(c) def oncreate_code(): c = invoke35c(0x6f, mi("Landroid/app/Activity;","onCreate","(Landroid/os/Bundle;)V"), [P0,P1]) c += bytes([0x22,V0]) + struct.pack('","(Landroid/content/Context;)V"), [V0,P0]) c += invoke35c(0x6e, mi("Landroid/webkit/WebView;","getSettings","()Landroid/webkit/WebSettings;"), [V0]) c += bytes([0x0c,V1]) c += bytes([0x12,(1<<4)|V2]) c += invoke35c(0x6e, mi("Landroid/webkit/WebSettings;","setJavaScriptEnabled","(Z)V"), [V1,V2]) c += invoke35c(0x6e, mi("Landroid/webkit/WebSettings;","setDomStorageEnabled","(Z)V"), [V1,V2]) c += invoke35c(0x6e, mi("Landroid/webkit/WebSettings;","setUseWideViewPort","(Z)V"), [V1,V2]) c += invoke35c(0x6e, mi("Landroid/app/Activity;","setContentView","(Landroid/view/View;)V"), [P0,V0]) c += bytes([0x1a,V1]) + struct.pack('","()V"), ci_init), (("Lcom/webwrapper/app/MainActivity;","onCreate","(Landroid/os/Bundle;)V"), ci_oncreate), (("Lcom/webwrapper/app/MainActivity;","onBackPressed","()V"), ci_onback), ]: while len(data)%4: data += b'\x00' ci_offs[key] = data_start + len(data) data += bytearray(ci) # Class data item while len(data)%4: data += b'\x00' class_data_off = data_start + len(data) mi_init = mi("Lcom/webwrapper/app/MainActivity;","","()V") mi_oncreate = mi("Lcom/webwrapper/app/MainActivity;","onCreate","(Landroid/os/Bundle;)V") mi_onback = mi("Lcom/webwrapper/app/MainActivity;","onBackPressed","()V") cd = bytearray() cd += _uleb128(0); cd += _uleb128(0); cd += _uleb128(1); cd += _uleb128(2) cd += _uleb128(mi_init); cd += _uleb128(0x10001); cd += _uleb128(ci_offs[("Lcom/webwrapper/app/MainActivity;","","()V")]) cd += _uleb128(mi_oncreate-mi_init); cd += _uleb128(1); cd += _uleb128(ci_offs[("Lcom/webwrapper/app/MainActivity;","onCreate","(Landroid/os/Bundle;)V")]) cd += _uleb128(mi_onback-mi_oncreate);cd += _uleb128(1); cd += _uleb128(ci_offs[("Lcom/webwrapper/app/MainActivity;","onBackPressed","()V")]) data += cd # Map list while len(data)%4: data += b'\x00' map_off = data_start + len(data) def map_item(t,c,o): return struct.pack(' 32767: return struct.pack('>15)|0x8000, ln&0x7FFF) + enc + b'\x00\x00' return struct.pack(' bytes: """ Genera AndroidManifest.xml en formato AXML binario (Android Binary XML). Este es el formato que Android requiere dentro de los APKs. """ main_activity = f"{package_name}.MainActivity" # ── Recopilar todos los strings únicos ── str_list = [] def add(s): if s not in str_list: str_list.append(s) return str_list.index(s) # Namespace ns_prefix_idx = add("") ns_uri_idx = add(ANDROID_NS) # Strings fijos add("package"); add("versionCode"); add("versionName") add("minSdkVersion"); add("targetSdkVersion") add("name"); add("label"); add("exported"); add("hardwareAccelerated") add("allowBackup"); add("configChanges"); add("usesCleartextTraffic") add("manifest"); add("uses-sdk"); add("uses-permission") add("application"); add("activity"); add("intent-filter") add("action"); add("category") add("1.0"); add("android.intent.action.MAIN") add("android.intent.category.LAUNCHER") add("android.permission.INTERNET") add(package_name); add(app_name); add(main_activity) permissions = ["android.permission.INTERNET"] if allow_downloads: permissions.append("android.permission.WRITE_EXTERNAL_STORAGE") add("android.permission.WRITE_EXTERNAL_STORAGE") if allow_geolocation: permissions.append("android.permission.ACCESS_FINE_LOCATION") add("android.permission.ACCESS_FINE_LOCATION") def si(s): return str_list.index(s) # ── Construir string pool ── str_data = bytearray() str_offsets = [] for s in str_list: str_offsets.append(len(str_data)) str_data += bytearray(_encode_utf16le(s)) str_data = bytearray(_pad4(bytes(str_data))) offsets_bytes = b''.join(struct.pack(' mf_attrs = ( attr(0xFFFFFFFF, si("package"), si(package_name), TYPE_STRING, si(package_name)) + int_attr(True, "versionCode", 1) + string_attr(True, "versionName", "1.0") ) xml_chunks += start_tag_chunk("", "manifest", mf_attrs) # sdk_attrs = int_attr(True,"minSdkVersion",21) + int_attr(True,"targetSdkVersion",34) xml_chunks += start_tag_chunk("","uses-sdk", sdk_attrs) xml_chunks += end_tag_chunk("","uses-sdk") # for each permission for perm in permissions: pattr = string_attr(True, "name", perm) xml_chunks += start_tag_chunk("","uses-permission", pattr) xml_chunks += end_tag_chunk("","uses-permission") # app_attrs = ( string_attr(True,"label",app_name) + bool_attr(True,"allowBackup",True) + bool_attr(True,"hardwareAccelerated",True) + bool_attr(True,"usesCleartextTraffic",True) ) xml_chunks += start_tag_chunk("","application", app_attrs) # config_changes = 0x6A0 # orientation|screenSize|keyboardHidden act_attrs = ( string_attr(True,"name",main_activity) + bool_attr(True,"exported",True) + int_attr(True,"hardwareAccelerated",True) + int_attr(True,"configChanges",config_changes) ) xml_chunks += start_tag_chunk("","activity",act_attrs) # xml_chunks += start_tag_chunk("","intent-filter",b"") # xml_chunks += start_tag_chunk("","action", string_attr(True,"name","android.intent.action.MAIN")) xml_chunks += end_tag_chunk("","action") # xml_chunks += start_tag_chunk("","category", string_attr(True,"name","android.intent.category.LAUNCHER")) xml_chunks += end_tag_chunk("","category") xml_chunks += end_tag_chunk("","intent-filter") xml_chunks += end_tag_chunk("","activity") xml_chunks += end_tag_chunk("","application") xml_chunks += end_tag_chunk("","manifest") # Namespace end xml_chunks += ns_chunk(0x0101, si(""), si(ANDROID_NS)) # ── Wrap in outer AXML file chunk ── inner = bytes(string_pool) + bytes(resource_chunk) + bytes(xml_chunks) file_size = 8 + len(inner) return struct.pack(' bytes: """Genera PNG con el color dado para usar como ícono.""" c = color_hex.lstrip("#") r,g,b = int(c[0:2],16), int(c[2:4],16), int(c[4:6],16) def chunk(name, data): c_data = name+data crc = zlib.crc32(c_data) & 0xFFFFFFFF return struct.pack(">I",len(data)) + c_data + struct.pack(">I",crc) sig = b"\x89PNG\r\n\x1a\n" ihdr = chunk(b"IHDR", struct.pack(">IIBBBBB",size,size,8,2,0,0,0)) raw = (b"\x00"+bytes([r,g,b])*size) * size idat = chunk(b"IDAT", zlib.compress(raw)) iend = chunk(b"IEND", b"") return sig + ihdr + idat + iend # ───────────────────────────────────────────────────────────────────────────── # SIGNING – JAR v1 en Python puro (sin jarsigner) # ───────────────────────────────────────────────────────────────────────────── from cryptography.hazmat.primitives import hashes as _hashes from cryptography.hazmat.primitives.asymmetric import rsa as _rsa, padding as _padding from cryptography.hazmat.backends import default_backend as _backend from cryptography import x509 as _x509 from cryptography.x509.oid import NameOID as _NameOID from cryptography.hazmat.primitives.serialization import pkcs7 as _pkcs7, Encoding as _Encoding import datetime as _dt _SIGNING_KEY = None _SIGNING_CERT = None def _get_signing_credentials(): """Genera (o devuelve) el par clave/cert RSA para firmar APKs.""" global _SIGNING_KEY, _SIGNING_CERT if _SIGNING_KEY is None: # Use fresh local imports to avoid any module-level aliasing issues from cryptography.hazmat.primitives.asymmetric import rsa as _local_rsa from cryptography.hazmat.primitives import hashes as _local_hashes from cryptography import x509 as _local_x509 from cryptography.x509.oid import NameOID as _local_NameOID import datetime as _local_dt key_path = KEYS_DIR / "signing.key.pem" cert_path = KEYS_DIR / "signing.cert.pem" if key_path.exists() and cert_path.exists(): from cryptography.hazmat.primitives.serialization import load_pem_private_key _SIGNING_KEY = load_pem_private_key(key_path.read_bytes(), password=None) _SIGNING_CERT = _local_x509.load_pem_x509_certificate(cert_path.read_bytes()) else: _SIGNING_KEY = _local_rsa.generate_private_key( public_exponent=65537, key_size=2048) subj = _local_x509.Name([ _local_x509.NameAttribute(_local_NameOID.COUNTRY_NAME, "US"), _local_x509.NameAttribute(_local_NameOID.ORGANIZATION_NAME, "WebToAPK Builder"), _local_x509.NameAttribute(_local_NameOID.COMMON_NAME, "WebToAPK"), ]) _SIGNING_CERT = ( _local_x509.CertificateBuilder() .subject_name(subj).issuer_name(subj) .public_key(_SIGNING_KEY.public_key()) .serial_number(12345) .not_valid_before(_local_dt.datetime(2024,1,1,tzinfo=_local_dt.timezone.utc)) .not_valid_after(_local_dt.datetime(2054,1,1,tzinfo=_local_dt.timezone.utc)) .sign(_SIGNING_KEY, _local_hashes.SHA256()) ) # Persistir para reutilizar entre builds from cryptography.hazmat.primitives.serialization import ( Encoding as _E, PrivateFormat as _PF, NoEncryption as _NE) key_path.write_bytes( _SIGNING_KEY.private_bytes(_E.PEM, _PF.PKCS8, _NE())) cert_path.write_bytes( _SIGNING_CERT.public_bytes(_E.PEM)) return _SIGNING_KEY, _SIGNING_CERT def _b64sha256(data: bytes) -> str: import hashlib, base64 return base64.b64encode(hashlib.sha256(data).digest()).decode() def _jar_sign(unsigned_apk: bytes) -> bytes: """Firma el APK con JAR signing v1 en Python puro.""" import io from cryptography.hazmat.primitives import hashes as _h from cryptography.hazmat.primitives.asymmetric import padding as _p from cryptography.hazmat.primitives.serialization import pkcs7 as _p7, Encoding as _Enc key, cert = _get_signing_credentials() zin = zipfile.ZipFile(io.BytesIO(unsigned_apk), 'r') # MANIFEST.MF R = "\r\n" mf_sections = {} mf_lines = [f"Manifest-Version: 1.0{R}Created-By: WebToAPK Builder{R}{R}"] for name in zin.namelist(): if name.startswith("META-INF/"): continue digest = _b64sha256(zin.read(name)) mf_sections[name] = digest mf_lines.append(f"Name: {name}{R}SHA-256-Digest: {digest}{R}{R}") manifest_mf = "".join(mf_lines).encode("utf-8") # CERT.SF sf_lines = [ f"Signature-Version: 1.0{R}" f"Created-By: WebToAPK Builder{R}" f"SHA-256-Digest-Manifest: {_b64sha256(manifest_mf)}{R}{R}" ] for name, digest in mf_sections.items(): sec = f"Name: {name}{R}SHA-256-Digest: {digest}{R}{R}".encode() sf_lines.append(f"Name: {name}{R}SHA-256-Digest: {_b64sha256(sec)}{R}{R}") cert_sf = "".join(sf_lines).encode("utf-8") # CERT.RSA (PKCS#7 detached) try: cert_rsa = ( _p7.PKCS7SignatureBuilder() .set_data(cert_sf) .add_signer(cert, key, _h.SHA256()) .sign(_Enc.DER, [_p7.PKCS7Options.DetachedSignature]) ) except Exception: cert_rsa = key.sign(cert_sf, _p.PKCS1v15(), _h.SHA256()) # Reassemble out = io.BytesIO() with zipfile.ZipFile(out, 'w') as zout: for name in zin.namelist(): if name.startswith("META-INF/"): continue info = zin.getinfo(name) zout.writestr(info, zin.read(name)) zout.writestr("META-INF/MANIFEST.MF", manifest_mf, zipfile.ZIP_DEFLATED) zout.writestr("META-INF/CERT.SF", cert_sf, zipfile.ZIP_DEFLATED) zout.writestr("META-INF/CERT.RSA", cert_rsa, zipfile.ZIP_STORED) zin.close() return out.getvalue() # ───────────────────────────────────────────────────────────────────────────── # APK ASSEMBLER & SIGNER # ───────────────────────────────────────────────────────────────────────────── def assemble_and_sign_apk( url: str, app_name: str, package_name: str, primary_color: str, allow_downloads: bool, allow_geolocation: bool, fullscreen: bool, work_dir: Path, ) -> Path: """ Ensambla un APK real con: - classes.dex con bytecode WebView compilado - AndroidManifest.xml en formato AXML binario - Íconos PNG reales en múltiples densidades - Metadatos del proyecto en assets/ - Firma JAR v1 con jarsigner """ safe = re.sub(r"[^a-zA-Z0-9]","_",app_name) apk_unsigned = work_dir / f"{safe}-unsigned.apk" apk_signed = work_dir / f"{safe}.apk" # ── 1. Generar componentes del APK ── dex_bytes = build_webview_dex(url) manifest_bytes = build_binary_manifest( package_name, app_name, fullscreen=fullscreen, allow_downloads=allow_downloads, allow_geolocation=allow_geolocation, ) # ── 2. Crear APK (ZIP sin compresión en manifest/dex para compatibilidad) ── with zipfile.ZipFile(apk_unsigned, "w") as zf: # AndroidManifest.xml – sin compresión (requerido) zf.writestr(zipfile.ZipInfo("AndroidManifest.xml"), manifest_bytes, compress_type=zipfile.ZIP_STORED) # classes.dex – sin compresión (mejor compatibilidad) zf.writestr(zipfile.ZipInfo("classes.dex"), dex_bytes, compress_type=zipfile.ZIP_STORED) # Íconos en todas las densidades icon_sizes = {"mdpi":48,"hdpi":72,"xhdpi":96,"xxhdpi":144,"xxxhdpi":192} for density, size in icon_sizes.items(): zf.writestr( zipfile.ZipInfo(f"res/mipmap-{density}/ic_launcher.png"), make_png(primary_color, size), compress_type=zipfile.ZIP_DEFLATED, ) # Assets config = { "app_name": app_name, "package_name": package_name, "url": url, "primary_color": primary_color, "built_with": "WebToAPK Builder", "version": "1.0", "min_sdk": 21, "target_sdk": 34, } zf.writestr("assets/config.json", json.dumps(config, ensure_ascii=False, indent=2), compress_type=zipfile.ZIP_DEFLATED) # ── 3. Firmar con JAR signing v1 (Python puro) ── unsigned_bytes = apk_unsigned.read_bytes() signed_bytes = _jar_sign(unsigned_bytes) apk_signed.write_bytes(signed_bytes) return apk_signed # ───────────────────────────────────────────────────────────────────────────── # LÓGICA PRINCIPAL # ───────────────────────────────────────────────────────────────────────────── def sanitize_package(name: str) -> str: name = name.lower().strip() name = re.sub(r"[^a-z0-9_]","_",name) name = re.sub(r"_+","_",name).strip("_") if not name or name[0].isdigit(): name = "app_"+name return f"com.webwrapper.{name[:40]}" def validate_url(url: str) -> tuple[bool,str]: url = url.strip() if not url: return False,"Por favor ingresa una URL." if not url.startswith(("http://","https://")): url = "https://"+url try: parsed = urlparse(url) if not parsed.netloc: return False,"URL inválida. Asegúrate de incluir el dominio." return True, url except Exception: return False,"URL inválida." def build_apk( url: str, app_name: str, theme_color: str, fullscreen: bool, allow_js: bool, allow_downloads: bool, allow_geolocation: bool, progress: gr.Progress = gr.Progress(track_tqdm=True), ) -> tuple[str|None,str]: # ── Validación ── progress(0.05, desc="Validando URL...") valid, url = validate_url(url) if not valid: return None, f"❌ {url}" if not app_name.strip(): app_name = urlparse(url).netloc.replace("www.","").split(".")[0].title() package_name = sanitize_package(app_name.replace(" ","_")) primary_color, _ = COLORS.get(theme_color, ("#1976D2","#FFFFFF")) work_dir = Path(tempfile.mkdtemp(prefix="webapk_")) try: progress(0.20, desc="Generando bytecode DEX...") progress(0.40, desc="Construyendo AndroidManifest binario...") progress(0.60, desc="Ensamblando y firmando APK...") apk_path = assemble_and_sign_apk( url=url, app_name=app_name, package_name=package_name, primary_color=primary_color, allow_downloads=allow_downloads, allow_geolocation=allow_geolocation, fullscreen=fullscreen, work_dir=work_dir, ) progress(0.85, desc="Copiando a directorio de salida...") safe_name = re.sub(r"[^a-zA-Z0-9_-]","_",app_name) final_path = OUTPUT_DIR / f"{safe_name}.apk" shutil.copy2(apk_path, final_path) size_kb = final_path.stat().st_size // 1024 progress(1.0, desc="¡APK listo!") msg = ( f"✅ **APK generado exitosamente**\n\n" f"📦 **App:** {app_name}\n" f"🔗 **URL:** {url}\n" f"📋 **Package:** `{package_name}`\n" f"📏 **Tamaño:** {size_kb} KB\n\n" f"⬇️ Descarga el APK y transfíerelo a tu Android.\n" f"Activa *Fuentes desconocidas* en Ajustes → Seguridad para instalarlo." ) return str(final_path), msg except Exception as e: import traceback return None, f"❌ Error al generar el APK:\n```\n{traceback.format_exc()}\n```" finally: shutil.rmtree(work_dir, ignore_errors=True) # ───────────────────────────────────────────────────────────────────────────── # GRADIO UI # ───────────────────────────────────────────────────────────────────────────── THEME = gr.themes.Base( primary_hue=gr.themes.colors.blue, secondary_hue=gr.themes.colors.slate, font=[gr.themes.GoogleFont("Inter"),"ui-sans-serif","sans-serif"], ) CSS = """ :root{--primary:#1a56db;--surface:#f8fafc;--border:#e2e8f0;--radius:12px} .container{max-width:860px;margin:0 auto;padding:24px 16px} .header{text-align:center;padding:32px 0 24px;border-bottom:1px solid var(--border);margin-bottom:28px} .header h1{font-size:2rem;font-weight:700;color:#0f172a;margin:0 0 8px} .header p{color:#64748b;font-size:1rem;margin:0} .badge{display:inline-block;background:#dbeafe;color:#1e40af;font-size:12px;font-weight:600; padding:4px 12px;border-radius:9999px;margin-bottom:12px;letter-spacing:.5px} .build-btn{background:var(--primary)!important} footer{display:none!important} """ def build_ui(): with gr.Blocks(theme=THEME, css=CSS, title="WebToAPK Builder") as demo: with gr.Column(elem_classes="container"): gr.HTML("""
🤗 Hugging Face Space

📱 WebToAPK Builder

Convierte cualquier sitio web en una APK real e instalable de Android.
Genera bytecode DEX auténtico + firma JAR. Sin Android Studio.

""") with gr.Row(): with gr.Column(scale=3): gr.Markdown("##### 🔗 Información de la app") url_input = gr.Textbox(label="URL del sitio web", placeholder="https://ejemplo.com", info="URL completa del sitio a convertir") name_input = gr.Textbox(label="Nombre de la aplicación", placeholder="Mi App (opcional – se detecta del dominio)", info="Nombre que verá el usuario al instalar la app") gr.Markdown("##### 🎨 Personalización") color_input = gr.Dropdown(choices=list(COLORS.keys()), value="Material Blue", label="Color principal del ícono") gr.Markdown("##### ⚙️ Opciones") with gr.Row(): js_check = gr.Checkbox(value=True, label="JavaScript habilitado") dl_check = gr.Checkbox(value=False, label="Permitir descargas") with gr.Row(): geo_check = gr.Checkbox(value=False, label="Geolocalización") full_check = gr.Checkbox(value=False, label="Pantalla completa") build_btn = gr.Button("⚡ Generar APK", variant="primary", elem_classes="build-btn") with gr.Column(scale=2): gr.Markdown("##### 📥 Resultado") output_file = gr.File(label="APK generado", file_types=[".apk"], interactive=False) output_msg = gr.Markdown( value="*Rellena los campos y haz clic en 'Generar APK'.*") gr.Markdown("---\n##### 💡 Sitios de ejemplo") gr.Examples( examples=[ ["https://wikipedia.org", "Wikipedia", "Material Blue", True, False, False, False], ["https://github.com", "GitHub", "Deep Purple", True, True, False, False], ["https://news.ycombinator.com", "Hacker News", "Orange", True, False, False, False], ["https://translate.google.com", "Google Translate","Material Green", True, False, True, False], ["https://web.whatsapp.com", "WhatsApp Web", "Material Green", True, False, False, True], ], inputs=[url_input, name_input, color_input, js_check, dl_check, geo_check, full_check], label="", ) gr.HTML("""
📌 ¿Cómo instalar el APK?
1. Descarga el archivo APK.
2. Transfiérelo a tu Android (cable, WhatsApp, Drive, etc.).
3. Ajustes → Seguridad → Fuentes desconocidas → activa.
4. Abre el APK y toca Instalar.

✅ APK real: Genera bytecode DEX compilado + firma JAR válida. Requiere internet en el dispositivo para cargar el sitio web.
""") build_btn.click( fn=build_apk, inputs=[url_input, name_input, color_input, full_check, js_check, dl_check, geo_check], outputs=[output_file, output_msg], ) return demo if __name__ == "__main__": app = build_ui() app.launch(server_name="0.0.0.0", server_port=7860, share=False, show_error=True)