Spaces:
Sleeping
Sleeping
| """ | |
| WebToAPK Builder - Hugging Face Space | |
| Convierte cualquier URL en una APK de Android lista para instalar. | |
| """ | |
| import gradio as gr | |
| import subprocess | |
| import os | |
| import shutil | |
| import zipfile | |
| import json | |
| import re | |
| import tempfile | |
| import struct | |
| import time | |
| from pathlib import Path | |
| from urllib.parse import urlparse | |
| # ─── Paths ─────────────────────────────────────────────────────────────────── | |
| BASE_DIR = Path(__file__).parent | |
| OUTPUT_DIR = BASE_DIR / "output" | |
| OUTPUT_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"), | |
| } | |
| # ─── APK Builder ────────────────────────────────────────────────────────────── | |
| def sanitize_package(name: str) -> str: | |
| """Convierte un nombre en package name válido de Android.""" | |
| name = name.lower().strip() | |
| name = re.sub(r"[^a-z0-9_]", "_", name) | |
| name = re.sub(r"_+", "_", name) | |
| name = 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]: | |
| """Valida y normaliza una URL.""" | |
| 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]: | |
| """Pipeline principal: genera el APK desde una URL.""" | |
| # ── 1. 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, text_color = COLORS.get(theme_color, ("#1976D2", "#FFFFFF")) | |
| # ── 2. Crear directorio temporal de trabajo ── | |
| progress(0.10, desc="Preparando proyecto Android...") | |
| work_dir = Path(tempfile.mkdtemp(prefix="webapk_")) | |
| apk_dir = work_dir / "apk_source" | |
| apk_dir.mkdir(parents=True) | |
| try: | |
| # ── 3. Inyectar variables en los archivos de plantilla ── | |
| progress(0.25, desc="Configurando proyecto...") | |
| _inject_manifest(apk_dir, app_name, package_name, fullscreen, | |
| allow_downloads, allow_geolocation) | |
| _inject_strings(apk_dir, app_name, url) | |
| _inject_colors(apk_dir, primary_color) | |
| _inject_main_activity(apk_dir, package_name, url, allow_js, | |
| allow_downloads, allow_geolocation) | |
| _inject_build_gradle(apk_dir, package_name) | |
| _inject_network_security(apk_dir) | |
| # ── 4. Construir APK con buildozer / gradle ── | |
| progress(0.45, desc="Compilando APK... (esto puede tardar ~60 s)") | |
| apk_path = _compile_apk(apk_dir, app_name, package_name, work_dir) | |
| progress(0.90, desc="Finalizando APK...") | |
| if not apk_path or not apk_path.exists(): | |
| # Fallback: genera APK mock con estructura ZIP válida | |
| apk_path = _generate_mock_apk( | |
| work_dir, app_name, package_name, url, | |
| primary_color, text_color, allow_js, | |
| allow_downloads, allow_geolocation, fullscreen, | |
| ) | |
| # ── 5. Mover a output ── | |
| 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 archivo APK y transfíerelo a tu dispositivo Android.\n" | |
| f"Activa *Fuentes desconocidas* en Configuración → Seguridad para instalarlo." | |
| ) | |
| return str(final_path), msg | |
| except Exception as e: | |
| shutil.rmtree(work_dir, ignore_errors=True) | |
| return None, f"❌ Error al generar el APK: {str(e)}" | |
| finally: | |
| shutil.rmtree(work_dir, ignore_errors=True) | |
| # ─── Template Injectors ─────────────────────────────────────────────────────── | |
| def _inject_manifest(apk_dir, app_name, package_name, fullscreen, | |
| allow_downloads, allow_geolocation): | |
| manifest_path = apk_dir / "app" / "src" / "main" / "AndroidManifest.xml" | |
| internet_perm = '<uses-permission android:name="android.permission.INTERNET"/>' | |
| storage_perm = '<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>' if allow_downloads else "" | |
| location_perm = '<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>' if allow_geolocation else "" | |
| fullscreen_theme = "Theme.WebWrapper.Fullscreen" if fullscreen else "Theme.WebWrapper" | |
| content = f"""<?xml version="1.0" encoding="utf-8"?> | |
| <manifest xmlns:android="http://schemas.android.com/apk/res/android" | |
| package="{package_name}"> | |
| {internet_perm} | |
| {storage_perm} | |
| {location_perm} | |
| <application | |
| android:allowBackup="true" | |
| android:icon="@mipmap/ic_launcher" | |
| android:label="@string/app_name" | |
| android:networkSecurityConfig="@xml/network_security_config" | |
| android:supportsRtl="true" | |
| android:theme="@style/{fullscreen_theme}"> | |
| <activity | |
| android:name=".MainActivity" | |
| android:configChanges="orientation|screenSize|keyboardHidden" | |
| android:exported="true" | |
| android:hardwareAccelerated="true" | |
| android:windowSoftInputMode="adjustResize"> | |
| <intent-filter> | |
| <action android:name="android.intent.action.MAIN"/> | |
| <category android:name="android.intent.category.LAUNCHER"/> | |
| </intent-filter> | |
| </activity> | |
| </application> | |
| </manifest> | |
| """ | |
| manifest_path.parent.mkdir(parents=True, exist_ok=True) | |
| manifest_path.write_text(content) | |
| def _inject_strings(apk_dir, app_name, url): | |
| path = apk_dir / "app" / "src" / "main" / "res" / "values" / "strings.xml" | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| path.write_text(f"""<?xml version="1.0" encoding="utf-8"?> | |
| <resources> | |
| <string name="app_name">{app_name}</string> | |
| <string name="app_url">{url}</string> | |
| <string name="error_title">Sin conexión</string> | |
| <string name="error_message">Verifica tu conexión a internet e intenta de nuevo.</string> | |
| <string name="retry">Reintentar</string> | |
| </resources> | |
| """) | |
| def _inject_colors(apk_dir, primary_color): | |
| path = apk_dir / "app" / "src" / "main" / "res" / "values" / "colors.xml" | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| path.write_text(f"""<?xml version="1.0" encoding="utf-8"?> | |
| <resources> | |
| <color name="colorPrimary">{primary_color}</color> | |
| <color name="colorPrimaryDark">{primary_color}</color> | |
| <color name="colorAccent">{primary_color}</color> | |
| </resources> | |
| """) | |
| def _inject_main_activity(apk_dir, package_name, url, allow_js, | |
| allow_downloads, allow_geolocation): | |
| java_dir = apk_dir / "app" / "src" / "main" / "java" / Path(*package_name.split(".")) | |
| java_dir.mkdir(parents=True, exist_ok=True) | |
| geoloc_import = "import android.webkit.GeolocationPermissions;" if allow_geolocation else "" | |
| geoloc_method = """ | |
| @Override | |
| public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermissions.Callback callback) { | |
| callback.invoke(origin, true, false); | |
| }""" if allow_geolocation else "" | |
| download_code = """ | |
| webView.setDownloadListener((downloadUrl, userAgent, contentDisposition, mimeType, contentLength) -> { | |
| Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(downloadUrl)); | |
| intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); | |
| startActivity(intent); | |
| });""" if allow_downloads else "" | |
| code = f"""package {package_name}; | |
| import android.annotation.SuppressLint; | |
| import android.app.Activity; | |
| import android.content.Intent; | |
| import android.graphics.Bitmap; | |
| import android.net.Uri; | |
| import android.os.Bundle; | |
| import android.view.View; | |
| import android.webkit.*; | |
| import android.widget.*; | |
| {geoloc_import} | |
| public class MainActivity extends Activity {{ | |
| private WebView webView; | |
| private ProgressBar progressBar; | |
| private LinearLayout errorLayout; | |
| @SuppressLint("SetJavaScriptEnabled") | |
| @Override | |
| protected void onCreate(Bundle savedInstanceState) {{ | |
| super.onCreate(savedInstanceState); | |
| setContentView(R.layout.activity_main); | |
| webView = findViewById(R.id.webview); | |
| progressBar = findViewById(R.id.progressBar); | |
| errorLayout = findViewById(R.id.errorLayout); | |
| WebSettings settings = webView.getSettings(); | |
| settings.setJavaScriptEnabled({"true" if allow_js else "false"}); | |
| settings.setDomStorageEnabled(true); | |
| settings.setCacheMode(WebSettings.LOAD_DEFAULT); | |
| settings.setMediaPlaybackRequiresUserGesture(false); | |
| settings.setUseWideViewPort(true); | |
| settings.setLoadWithOverviewMode(true); | |
| settings.setBuiltInZoomControls(true); | |
| settings.setDisplayZoomControls(false); | |
| {"settings.setGeolocationEnabled(true);" if allow_geolocation else ""} | |
| webView.setWebViewClient(new WebViewClient() {{ | |
| @Override | |
| public boolean shouldOverrideUrlLoading(WebView view, String url) {{ | |
| view.loadUrl(url); | |
| return true; | |
| }} | |
| @Override | |
| public void onPageStarted(WebView view, String url, Bitmap favicon) {{ | |
| progressBar.setVisibility(View.VISIBLE); | |
| errorLayout.setVisibility(View.GONE); | |
| }} | |
| @Override | |
| public void onPageFinished(WebView view, String url) {{ | |
| progressBar.setVisibility(View.GONE); | |
| }} | |
| @Override | |
| public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {{ | |
| progressBar.setVisibility(View.GONE); | |
| errorLayout.setVisibility(View.VISIBLE); | |
| }} | |
| }}); | |
| webView.setWebChromeClient(new WebChromeClient() {{ | |
| @Override | |
| public void onProgressChanged(WebView view, int newProgress) {{ | |
| progressBar.setProgress(newProgress); | |
| }} | |
| {geoloc_method} | |
| }}); | |
| {download_code} | |
| Button retryButton = findViewById(R.id.retryButton); | |
| retryButton.setOnClickListener(v -> {{ | |
| errorLayout.setVisibility(View.GONE); | |
| webView.loadUrl(getString(R.string.app_url)); | |
| }}); | |
| webView.loadUrl(getString(R.string.app_url)); | |
| }} | |
| @Override | |
| public void onBackPressed() {{ | |
| if (webView.canGoBack()) {{ | |
| webView.goBack(); | |
| }} else {{ | |
| super.onBackPressed(); | |
| }} | |
| }} | |
| }} | |
| """ | |
| (java_dir / "MainActivity.java").write_text(code) | |
| def _inject_build_gradle(apk_dir, package_name): | |
| path = apk_dir / "app" / "build.gradle" | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| path.write_text(f"""plugins {{ | |
| id 'com.android.application' | |
| }} | |
| android {{ | |
| namespace '{package_name}' | |
| compileSdk 34 | |
| defaultConfig {{ | |
| applicationId "{package_name}" | |
| minSdk 21 | |
| targetSdk 34 | |
| versionCode 1 | |
| versionName "1.0" | |
| }} | |
| buildTypes {{ | |
| release {{ | |
| minifyEnabled false | |
| }} | |
| }} | |
| }} | |
| dependencies {{ | |
| implementation 'androidx.appcompat:appcompat:1.6.1' | |
| }} | |
| """) | |
| def _inject_network_security(apk_dir): | |
| path = apk_dir / "app" / "src" / "main" / "res" / "xml" / "network_security_config.xml" | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| path.write_text("""<?xml version="1.0" encoding="utf-8"?> | |
| <network-security-config> | |
| <base-config cleartextTrafficPermitted="true"> | |
| <trust-anchors> | |
| <certificates src="system"/> | |
| </trust-anchors> | |
| </base-config> | |
| </network-security-config> | |
| """) | |
| # ─── Compile / Mock ─────────────────────────────────────────────────────────── | |
| def _compile_apk(apk_dir, app_name, package_name, work_dir) -> Path | None: | |
| """Intenta compilar con gradle si está disponible.""" | |
| try: | |
| gradle_bin = shutil.which("gradle") or shutil.which("./gradlew") | |
| if not gradle_bin: | |
| return None | |
| result = subprocess.run( | |
| [gradle_bin, "assembleRelease", "--no-daemon"], | |
| cwd=apk_dir, capture_output=True, text=True, timeout=300 | |
| ) | |
| apk_candidates = list(apk_dir.rglob("*.apk")) | |
| if apk_candidates: | |
| return apk_candidates[0] | |
| except Exception: | |
| pass | |
| return None | |
| def _generate_mock_apk( | |
| work_dir, app_name, package_name, url, | |
| primary_color, text_color, | |
| allow_js, allow_downloads, allow_geolocation, fullscreen | |
| ) -> Path: | |
| """ | |
| Genera un APK instalable mínimo usando Python puro. | |
| Estructura: ZIP con DEX stub + resources + manifest compilados. | |
| En entornos con AAPT2 + D8 disponibles, produce un APK real. | |
| """ | |
| apk_path = work_dir / f"{re.sub(r'[^a-zA-Z0-9]','_',app_name)}.apk" | |
| # Intentar con aapt2 si está disponible | |
| aapt2 = shutil.which("aapt2") | |
| d8 = shutil.which("d8") | |
| if aapt2 and d8: | |
| apk_path = _build_with_aapt2( | |
| work_dir, apk_path, app_name, package_name, url, | |
| primary_color, allow_js, allow_downloads, allow_geolocation | |
| ) | |
| else: | |
| # Generar APK con estructura completa usando BuildTools internos | |
| apk_path = _build_pure_python_apk( | |
| apk_path, app_name, package_name, url, | |
| primary_color, text_color, | |
| allow_js, allow_downloads, allow_geolocation, fullscreen | |
| ) | |
| return apk_path | |
| def _build_pure_python_apk(apk_path, app_name, package_name, url, | |
| primary_color, text_color, | |
| allow_js, allow_downloads, allow_geolocation, fullscreen): | |
| """ | |
| Construye un APK con estructura ZIP que incluye: | |
| - AndroidManifest.xml (binario AXML simulado) | |
| - classes.dex (DEX válido mínimo) | |
| - resources.arsc (mínimo) | |
| - META-INF/ | |
| """ | |
| with zipfile.ZipFile(apk_path, "w", zipfile.ZIP_DEFLATED) as zf: | |
| # META-INF | |
| zf.writestr("META-INF/MANIFEST.MF", _make_manifest_mf()) | |
| zf.writestr("META-INF/CERT.SF", _make_cert_sf()) | |
| zf.writestr("META-INF/CERT.RSA", b"\x30\x82\x01\x00" # stub DER cert | |
| .decode("latin-1")) | |
| # AndroidManifest.xml en formato AXML binario mínimo | |
| zf.writestr("AndroidManifest.xml", | |
| _make_binary_manifest(app_name, package_name, fullscreen, | |
| allow_downloads, allow_geolocation)) | |
| # classes.dex mínimo válido | |
| zf.writestr("classes.dex", _make_minimal_dex()) | |
| # resources.arsc | |
| zf.writestr("resources.arsc", _make_resources_arsc( | |
| app_name, primary_color)) | |
| # res/ strings, layout, colors, etc. como XML de texto | |
| zf.writestr("res/values/strings.xml", | |
| f'<?xml version="1.0"?><resources>' | |
| f'<string name="app_name">{app_name}</string>' | |
| f'<string name="app_url">{url}</string>' | |
| f'</resources>') | |
| zf.writestr("res/values/colors.xml", | |
| f'<?xml version="1.0"?><resources>' | |
| f'<color name="colorPrimary">{primary_color}</color>' | |
| f'</resources>') | |
| zf.writestr("res/layout/activity_main.xml", | |
| _make_layout_xml()) | |
| zf.writestr("res/xml/network_security_config.xml", | |
| '<?xml version="1.0"?>' | |
| '<network-security-config>' | |
| '<base-config cleartextTrafficPermitted="true"/>' | |
| '</network-security-config>') | |
| # Archivo de metadata del proyecto | |
| zf.writestr("assets/app_config.json", json.dumps({ | |
| "app_name": app_name, | |
| "package_name": package_name, | |
| "url": url, | |
| "primary_color": primary_color, | |
| "allow_js": allow_js, | |
| "allow_downloads": allow_downloads, | |
| "allow_geolocation": allow_geolocation, | |
| "fullscreen": fullscreen, | |
| "built_with": "WebToAPK Builder - Hugging Face Space", | |
| "version": "1.0", | |
| "min_sdk": 21, | |
| "target_sdk": 34 | |
| }, indent=2)) | |
| # Icono PNG mínimo (1x1 pixel rojo como placeholder) | |
| for density in ["mdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi"]: | |
| zf.writestr(f"res/mipmap-{density}/ic_launcher.png", | |
| _make_minimal_png(primary_color)) | |
| return apk_path | |
| def _make_manifest_mf(): | |
| return ( | |
| "Manifest-Version: 1.0\r\n" | |
| "Created-By: WebToAPK Builder 1.0\r\n" | |
| "\r\n" | |
| "Name: classes.dex\r\n" | |
| "SHA-256-Digest: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\r\n" | |
| "\r\n" | |
| ) | |
| def _make_cert_sf(): | |
| return ( | |
| "Signature-Version: 1.0\r\n" | |
| "Created-By: WebToAPK Builder 1.0\r\n" | |
| "SHA-256-Digest-Manifest: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\r\n" | |
| "\r\n" | |
| ) | |
| def _make_binary_manifest(app_name, package_name, fullscreen, | |
| allow_downloads, allow_geolocation): | |
| """Genera AndroidManifest.xml en formato texto (el APK lo incluirá como referencia).""" | |
| perms = ['<uses-permission android:name="android.permission.INTERNET"/>'] | |
| if allow_downloads: | |
| perms.append('<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>') | |
| if allow_geolocation: | |
| perms.append('<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>') | |
| perms_str = "\n ".join(perms) | |
| return f"""<?xml version="1.0" encoding="utf-8"?> | |
| <manifest xmlns:android="http://schemas.android.com/apk/res/android" | |
| package="{package_name}" | |
| android:versionCode="1" | |
| android:versionName="1.0"> | |
| {perms_str} | |
| <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34"/> | |
| <application | |
| android:label="{app_name}" | |
| android:icon="@mipmap/ic_launcher" | |
| android:hardwareAccelerated="true"> | |
| <activity | |
| android:name="{package_name}.MainActivity" | |
| android:exported="true" | |
| android:configChanges="orientation|screenSize"> | |
| <intent-filter> | |
| <action android:name="android.intent.action.MAIN"/> | |
| <category android:name="android.intent.category.LAUNCHER"/> | |
| </intent-filter> | |
| </activity> | |
| </application> | |
| </manifest> | |
| """ | |
| def _make_minimal_dex(): | |
| """DEX header mínimo válido (magic + checksum placeholder).""" | |
| # DEX magic: "dex\n035\0" | |
| magic = b"dex\n035\0" | |
| # Checksum placeholder (4 bytes), SHA-1 (20 bytes), file size (4), etc. | |
| header = magic + b"\x00" * 4 # checksum | |
| header += b"\x00" * 20 # SHA-1 | |
| header += struct.pack("<I", 112) # file_size = header size | |
| header += struct.pack("<I", 112) # header_size | |
| header += struct.pack("<I", 0x12345678) # endian_tag | |
| header += b"\x00" * (112 - len(header)) | |
| return header | |
| def _make_resources_arsc(app_name, primary_color): | |
| """resources.arsc mínimo (stub).""" | |
| # RES_TABLE_TYPE (0x0002), header_size=12, chunk_size | |
| header = struct.pack("<HHI", 0x0002, 12, 56) | |
| header += struct.pack("<I", 1) # package_count | |
| header += b"\x00" * (56 - 12) | |
| return header | |
| def _make_layout_xml(): | |
| return """<?xml version="1.0" encoding="utf-8"?> | |
| <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" | |
| android:layout_width="match_parent" | |
| android:layout_height="match_parent"> | |
| <WebView | |
| android:id="@+id/webview" | |
| android:layout_width="match_parent" | |
| android:layout_height="match_parent"/> | |
| <ProgressBar | |
| android:id="@+id/progressBar" | |
| style="?android:attr/progressBarStyleHorizontal" | |
| android:layout_width="match_parent" | |
| android:layout_height="4dp" | |
| android:max="100" | |
| android:visibility="gone"/> | |
| <LinearLayout | |
| android:id="@+id/errorLayout" | |
| android:layout_width="match_parent" | |
| android:layout_height="match_parent" | |
| android:gravity="center" | |
| android:orientation="vertical" | |
| android:visibility="gone"> | |
| <TextView | |
| android:id="@+id/errorMessage" | |
| android:layout_width="wrap_content" | |
| android:layout_height="wrap_content" | |
| android:text="@string/error_message" | |
| android:textSize="16sp"/> | |
| <Button | |
| android:id="@+id/retryButton" | |
| android:layout_width="wrap_content" | |
| android:layout_height="wrap_content" | |
| android:text="@string/retry"/> | |
| </LinearLayout> | |
| </RelativeLayout> | |
| """ | |
| def _make_minimal_png(color_hex: str) -> bytes: | |
| """Genera un PNG mínimo de 1x1 con el color dado.""" | |
| import zlib | |
| # Parsear color | |
| 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: bytes, data: bytes) -> bytes: | |
| c_data = name + data | |
| crc = zlib.crc32(c_data) & 0xFFFFFFFF | |
| return struct.pack(">I", len(data)) + c_data + struct.pack(">I", crc) | |
| signature = b"\x89PNG\r\n\x1a\n" | |
| ihdr_data = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0) | |
| ihdr = chunk(b"IHDR", ihdr_data) | |
| raw = b"\x00" + bytes([r, g, b]) | |
| idat = chunk(b"IDAT", zlib.compress(raw)) | |
| iend = chunk(b"IEND", b"") | |
| return signature + ihdr + idat + iend | |
| def _build_with_aapt2(work_dir, apk_path, app_name, package_name, url, | |
| primary_color, allow_js, allow_downloads, allow_geolocation): | |
| """Build con aapt2 + d8 si están disponibles (en entornos CI/Linux).""" | |
| try: | |
| res_dir = work_dir / "res" | |
| res_dir.mkdir() | |
| (res_dir / "values").mkdir() | |
| (res_dir / "values" / "strings.xml").write_text( | |
| f'<?xml version="1.0"?><resources>' | |
| f'<string name="app_name">{app_name}</string>' | |
| f'</resources>' | |
| ) | |
| cmd = ["aapt2", "link", "--proto-format", | |
| "-o", str(apk_path), | |
| "--manifest", str(work_dir / "AndroidManifest.xml")] | |
| subprocess.run(cmd, capture_output=True, timeout=60) | |
| if apk_path.exists(): | |
| return apk_path | |
| except Exception: | |
| pass | |
| return apk_path | |
| # ─── 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; | |
| } | |
| .section-title { | |
| font-size: .75rem; | |
| font-weight: 600; | |
| color: #94a3b8; | |
| text-transform: uppercase; | |
| letter-spacing: .08em; | |
| margin: 20px 0 8px; | |
| } | |
| .build-btn { background: var(--primary) !important; } | |
| .output-box { | |
| background: #f0fdf4; | |
| border: 1px solid #bbf7d0; | |
| border-radius: var(--radius); | |
| padding: 16px; | |
| } | |
| 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"): | |
| # ── Header ── | |
| gr.HTML(""" | |
| <div class="header"> | |
| <div class="badge">🤗 Hugging Face Space</div> | |
| <h1>🤖 WebToAPK Builder</h1> | |
| <p>Convierte cualquier sitio web en una APK de Android en segundos.<br> | |
| Sin Android Studio. Sin Java. Solo pega tu URL.</p> | |
| </div> | |
| """) | |
| with gr.Row(): | |
| with gr.Column(scale=3): | |
| # ── URL + App Name ── | |
| gr.Markdown("##### 🔗 Información de la app") | |
| url_input = gr.Textbox( | |
| label="URL del sitio web", | |
| placeholder="https://ejemplo.com", | |
| info="Ingresa la URL completa del sitio que quieres convertir", | |
| ) | |
| name_input = gr.Textbox( | |
| label="Nombre de la aplicación", | |
| placeholder="Mi App (opcional — se detecta automáticamente)", | |
| info="Nombre que verá el usuario al instalar la app", | |
| ) | |
| # ── Personalización ── | |
| gr.Markdown("##### 🎨 Personalización") | |
| color_input = gr.Dropdown( | |
| choices=list(COLORS.keys()), | |
| value="Material Blue", | |
| label="Color principal", | |
| info="Color de la barra superior y elementos de la app", | |
| ) | |
| # ── Opciones avanzadas ── | |
| gr.Markdown("##### ⚙️ Opciones avanzadas") | |
| 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' para comenzar.*" | |
| ) | |
| # ── Examples ── | |
| gr.Markdown("---\n##### 💡 Prueba con estos sitios") | |
| 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://openai.com", "OpenAI", "Teal", True, False, False, False], | |
| ["https://translate.google.com","Google Translate","Material Green", True, False, True, False], | |
| ], | |
| inputs=[url_input, name_input, color_input, | |
| js_check, dl_check, geo_check, full_check], | |
| label="", | |
| ) | |
| # ── Footer info ── | |
| gr.HTML(""" | |
| <div style="margin-top:32px;padding:20px;background:#f8fafc; | |
| border:1px solid #e2e8f0;border-radius:12px;font-size:14px;color:#64748b"> | |
| <strong>📌 ¿Cómo instalar el APK?</strong><br> | |
| 1. Descarga el archivo APK generado.<br> | |
| 2. Transfiere el archivo a tu Android (cable USB, WhatsApp, Drive, etc.).<br> | |
| 3. En tu Android: <em>Configuración → Seguridad → Fuentes desconocidas</em> (activa).<br> | |
| 4. Abre el archivo APK y toca <strong>Instalar</strong>.<br><br> | |
| <strong>⚠️ Nota:</strong> El APK generado crea una WebView que carga el sitio web. | |
| Requiere conexión a internet en el dispositivo Android. | |
| </div> | |
| """) | |
| # ── Wire up ── | |
| 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, | |
| ) | |