""" 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 (compatible con gradio 4.x, 5.x y 6.x) # ───────────────────────────────────────────────────────────────────────────── CSS = """ .header-box{text-align:center;padding:28px 0 20px;margin-bottom:20px; border-bottom:1px solid #e2e8f0} .header-box h1{font-size:1.9rem;font-weight:700;color:#0f172a;margin:0 0 6px} .header-box p{color:#64748b;font-size:.97rem;margin:0} .info-box{margin-top:24px;padding:18px;background:#f8fafc; border:1px solid #e2e8f0;border-radius:10px; font-size:.88rem;line-height:1.6;color:#475569} footer{display:none!important} """ def _safe_theme(): """Construye el tema evitando APIs que no existen en todas las versiones.""" try: return gr.themes.Soft() except Exception: try: return gr.themes.Base() except Exception: return None def build_ui(): theme = _safe_theme() kwargs = dict(css=CSS, title="WebToAPK Builder") if theme is not None: kwargs["theme"] = theme with gr.Blocks(**kwargs) as demo: gr.HTML("""

📱 WebToAPK Builder

Convierte cualquier sitio web en una APK real e instalable de Android.
DEX bytecode + AndroidManifest AXML binario + firma JAR. Sin Android Studio.

""") with gr.Row(): # ── Columna izquierda: configuración ── with gr.Column(scale=3): gr.Markdown("#### 🔗 Sitio web") url_input = gr.Textbox( label="URL del sitio web", placeholder="https://ejemplo.com", ) name_input = gr.Textbox( label="Nombre de la app (opcional)", placeholder="Se detecta automáticamente del dominio", ) gr.Markdown("#### 🎨 Personalización") color_input = gr.Dropdown( choices=list(COLORS.keys()), value="Material Blue", label="Color del ícono", ) gr.Markdown("#### ⚙️ Opciones") with gr.Row(): js_check = gr.Checkbox(value=True, label="JavaScript") dl_check = gr.Checkbox(value=False, label="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") # ── Columna derecha: resultado ── with gr.Column(scale=2): gr.Markdown("#### 📥 Resultado") output_file = gr.File(label="APK generado (.apk)") output_msg = gr.Markdown( value="*Completa los campos y pulsa **Generar APK**.*" ) # Ejemplos gr.Markdown("---\n#### 💡 Ejemplos rápidos") 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?
1. Descarga el APK · 2. Pásalo al Android (Drive, cable, etc.)
3. Ajustes → Seguridad → Fuentes desconocidas → activar
4. Abre el APK y toca Instalar

Requiere conexión a internet en el dispositivo para cargar el sitio. minSdkVersion 21 (Android 5.0+).
""") 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)