Spaces:
Build error
Build error
| """ | |
| 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", | |
| "<init>", | |
| 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": "<init>", "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": "<init>", "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": "<init>", "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;","<init>","()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('<H', ti("Landroid/webkit/WebView;")) | |
| c += invoke35c(0x70, mi("Landroid/webkit/WebView;","<init>","(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('<H', si(padded_url)) | |
| c += invoke35c(0x6e, mi("Landroid/webkit/WebView;","loadUrl","(Ljava/lang/String;)V"), [V0,V1]) | |
| c += bytes([0x0e,0x00]) | |
| return _pad4(c) | |
| def onback_code(): | |
| c = invoke35c(0x6f, mi("Landroid/app/Activity;","onBackPressed","()V"), [P0]) | |
| c += bytes([0x0e,0x00]) | |
| return _pad4(c) | |
| def code_item(regs, ins, outs, code): | |
| insns_size = len(code)//2 | |
| return struct.pack('<HHHHI', regs,ins,outs,0,0) + struct.pack('<I', insns_size) + code | |
| ci_init = code_item(1,1,1, init_code()) | |
| ci_oncreate = code_item(5,2,2, oncreate_code()) | |
| ci_onback = code_item(1,1,1, onback_code()) | |
| # ββ Assemble DEX ββ | |
| HEADER_SIZE = 112 | |
| string_ids_size = len(ALL_STRINGS)*4 | |
| type_ids_size = len(TYPE_LIST)*4 | |
| proto_ids_size = len(PROTO_LIST)*12 | |
| method_ids_size = len(METHOD_LIST)*8 | |
| class_defs_size = 32 | |
| data_start = HEADER_SIZE + string_ids_size + type_ids_size + proto_ids_size + method_ids_size + class_defs_size | |
| data_start = (data_start + 3) & ~3 | |
| data = bytearray() | |
| # String data | |
| str_data_base = data_start + len(data) | |
| str_offsets = [] | |
| for s in ALL_STRINGS: | |
| str_offsets.append(str_data_base + len(data)) | |
| data += bytearray(_string_data_item(s)) | |
| data = bytearray(_pad4(bytes(data))) | |
| # Type lists (proto params) | |
| tl_offsets = [] | |
| for p in PROTO_LIST: | |
| if p["params"]: | |
| while len(data)%4: data += b'\x00' | |
| tl_offsets.append(data_start + len(data)) | |
| data += struct.pack('<I', len(p["params"])) | |
| for tp in p["params"]: | |
| data += struct.pack('<H', ti(tp)) | |
| while len(data)%4: data += b'\x00' | |
| else: | |
| tl_offsets.append(0) | |
| # Code items | |
| ci_offs = {} | |
| for key, ci in [ | |
| (("Lcom/webwrapper/app/MainActivity;","<init>","()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;","<init>","()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;","<init>","()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('<HHII',t,0,c,o) | |
| proto_ids_off_val = HEADER_SIZE + string_ids_size + type_ids_size | |
| meth_ids_off_val = proto_ids_off_val + proto_ids_size | |
| class_defs_off_v = meth_ids_off_val + method_ids_size | |
| tl_used = [o for o in tl_offsets if o!=0] | |
| map_items = [ | |
| map_item(0x0000,1,0), | |
| map_item(0x0001,len(ALL_STRINGS),HEADER_SIZE), | |
| map_item(0x0002,len(TYPE_LIST),HEADER_SIZE+string_ids_size), | |
| map_item(0x0003,len(PROTO_LIST),proto_ids_off_val), | |
| map_item(0x0005,len(METHOD_LIST),meth_ids_off_val), | |
| map_item(0x0006,1,class_defs_off_v), | |
| map_item(0x2002,len(ALL_STRINGS),str_data_base), | |
| ] | |
| if tl_used: | |
| map_items.append(map_item(0x1001,len(tl_used),min(tl_used))) | |
| map_items += [ | |
| map_item(0x2001,3,min(ci_offs.values())), | |
| map_item(0x2000,1,class_data_off), | |
| map_item(0x1000,1,map_off), | |
| ] | |
| data += struct.pack('<I',len(map_items)) + b''.join(map_items) | |
| # ββ Build sections ββ | |
| string_ids = b''.join(struct.pack('<I',o) for o in str_offsets) | |
| type_ids = b''.join(struct.pack('<I',si(t)) for t in TYPE_LIST) | |
| proto_ids = b''.join(struct.pack('<III',si(p["shorty"]),ti(p["ret"]),tl_offsets[i]) for i,p in enumerate(PROTO_LIST)) | |
| method_ids = b''.join(struct.pack('<HHI',ti(m["class"]),pi(m["proto"]),si(m["name"])) for m in METHOD_LIST) | |
| class_defs = struct.pack('<IIIIIIII', | |
| ti("Lcom/webwrapper/app/MainActivity;"), 0x0001, | |
| ti("Landroid/app/Activity;"), 0, 0xFFFFFFFF, 0, class_data_off, 0) | |
| body = bytearray(string_ids + type_ids + proto_ids + method_ids + class_defs) | |
| while HEADER_SIZE + len(body) < data_start: body += b'\x00' | |
| body += data | |
| file_size = HEADER_SIZE + len(body) | |
| header = bytearray(HEADER_SIZE) | |
| header[0:8] = b"dex\n035\x00" | |
| struct.pack_into('<I', header, 32, file_size) | |
| struct.pack_into('<I', header, 36, HEADER_SIZE) | |
| struct.pack_into('<I', header, 40, 0x12345678) | |
| struct.pack_into('<I', header, 52, map_off) | |
| struct.pack_into('<I', header, 56, len(ALL_STRINGS)); struct.pack_into('<I', header, 60, HEADER_SIZE) | |
| struct.pack_into('<I', header, 64, len(TYPE_LIST)); struct.pack_into('<I', header, 68, HEADER_SIZE+string_ids_size) | |
| struct.pack_into('<I', header, 72, len(PROTO_LIST)); struct.pack_into('<I', header, 76, proto_ids_off_val) | |
| struct.pack_into('<I', header, 80, 0); struct.pack_into('<I', header, 84, 0) | |
| struct.pack_into('<I', header, 88, len(METHOD_LIST)); struct.pack_into('<I', header, 92, meth_ids_off_val) | |
| struct.pack_into('<I', header, 96, 1); struct.pack_into('<I', header, 100, class_defs_off_v) | |
| struct.pack_into('<I', header, 104, len(data)); struct.pack_into('<I', header, 108, data_start) | |
| full = bytes(header) + bytes(body) | |
| full = bytearray(full) | |
| full[12:32] = hashlib.sha1(full[32:]).digest() | |
| struct.pack_into('<I', full, 8, zlib.adler32(bytes(full[12:])) & 0xFFFFFFFF) | |
| return bytes(full) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # AXML BUILDER β genera AndroidManifest.xml binario real | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ANDROID_NS = "http://schemas.android.com/apk/res/android" | |
| # Android attribute resource IDs | |
| _ATTR_IDS = { | |
| "versionCode": 0x0101021b, | |
| "versionName": 0x0101021c, | |
| "minSdkVersion": 0x0101020c, | |
| "targetSdkVersion": 0x01010270, | |
| "name": 0x01010003, | |
| "label": 0x01010001, | |
| "icon": 0x01010002, | |
| "exported": 0x01010010, | |
| "hardwareAccelerated": 0x010102b3, | |
| "allowBackup": 0x01010280, | |
| "configChanges": 0x0101001f, | |
| "usesCleartextTraffic": 0x010104ec, | |
| } | |
| def _encode_utf16le(s): | |
| enc = s.encode("utf-16-le") | |
| ln = len(s) | |
| if ln > 32767: | |
| return struct.pack('<HH',(ln>>15)|0x8000, ln&0x7FFF) + enc + b'\x00\x00' | |
| return struct.pack('<H', ln) + enc + b'\x00\x00' | |
| def build_binary_manifest(package_name: str, app_name: str, | |
| fullscreen=False, allow_downloads=False, | |
| allow_geolocation=False) -> 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('<I',o) for o in str_offsets) | |
| pool_header_size = 28 | |
| pool_total_size = pool_header_size + len(offsets_bytes) + len(str_data) | |
| pool_total_size = (pool_total_size + 3) & ~3 | |
| string_pool = struct.pack('<HHIIIIII', | |
| 0x0001, 28, pool_total_size, | |
| len(str_list), 0, 0, | |
| 28 + len(offsets_bytes), 0 | |
| ) + offsets_bytes + bytes(str_data) | |
| string_pool = _pad4(string_pool) | |
| # ββ Resource IDs chunk ββ | |
| attr_order = ["versionCode","versionName","minSdkVersion","targetSdkVersion", | |
| "name","label","exported","hardwareAccelerated","allowBackup", | |
| "configChanges","usesCleartextTraffic"] | |
| res_ids = b''.join(struct.pack('<I',_ATTR_IDS[a]) for a in attr_order if a in _ATTR_IDS) | |
| res_chunk_size = 8 + len(res_ids) | |
| resource_chunk = struct.pack('<HHI', 0x0180, 8, res_chunk_size) + res_ids | |
| # ββ Helper to build XML chunks ββ | |
| def ns_chunk(chunk_type, prefix_idx, uri_idx): | |
| # ResXMLTree_namespaceExt: lineNumber(I), comment(I), prefix(I), uri(I) | |
| body = struct.pack('<IIII', 1, 0xFFFFFFFF, prefix_idx, uri_idx) | |
| return struct.pack('<HHI', chunk_type, 16, 8 + len(body)) + body | |
| TYPE_STRING = 0x03 # string value | |
| TYPE_INT = 0x10 # integer | |
| TYPE_BOOL = 0x12 # boolean | |
| def attr(ns_idx, name_idx, value_str_idx, value_type, value_data): | |
| # ResXMLTree_attribute: ns(I), name(I), rawValue(I), size(H), res0(B), dataType(B), data(I) | |
| return struct.pack('<IIIHBBi', | |
| ns_idx, name_idx, value_str_idx, | |
| 8, 0, value_type, value_data | |
| ) | |
| def string_attr(ns_idx, name, value_str): | |
| ns = si(ANDROID_NS) if ns_idx else 0xFFFFFFFF | |
| return attr(ns, si(name), si(value_str), TYPE_STRING, si(value_str)) | |
| def int_attr(ns_idx, name, value): | |
| ns = si(ANDROID_NS) if ns_idx else 0xFFFFFFFF | |
| return attr(ns, si(name), 0xFFFFFFFF, TYPE_INT, value) | |
| def bool_attr(ns_idx, name, value): | |
| ns = si(ANDROID_NS) if ns_idx else 0xFFFFFFFF | |
| return attr(ns, si(name), 0xFFFFFFFF, TYPE_BOOL, -1 if value else 0) | |
| def start_tag_chunk(ns_str, tag_name, attrs_data): | |
| # ResXMLTree_attrExt: line(I), comment(I), ns(I), name(I), | |
| # attrStart(H), attrSize(H), attrCount(H), idAttr(H), classAttr(H), styleAttr(H) | |
| ns = si(ns_str) if ns_str else 0xFFFFFFFF | |
| attr_count = len(attrs_data) // 20 | |
| body = struct.pack('<IIIIHHHHHH', | |
| 1, # line number | |
| 0xFFFFFFFF, # comment | |
| ns, si(tag_name), | |
| 20, # attr_start (size of this struct) | |
| 20, # attr_size (size of each attr) | |
| attr_count, # attr_count | |
| 0, 0, 0 # id/class/style attr indices (0=none) | |
| ) + attrs_data | |
| return struct.pack('<HHI', 0x0102, 16, 8 + len(body)) + body | |
| def end_tag_chunk(ns_str, tag_name): | |
| # ResXMLTree_endElementExt: line(I), comment(I), ns(I), name(I) | |
| ns = si(ns_str) if ns_str else 0xFFFFFFFF | |
| body = struct.pack('<IIII', 1, 0xFFFFFFFF, ns, si(tag_name)) | |
| return struct.pack('<HHI', 0x0103, 16, 8 + len(body)) + body | |
| # ββ Build XML event stream ββ | |
| xml_chunks = bytearray() | |
| # Namespace start | |
| xml_chunks += ns_chunk(0x0100, si(""), si(ANDROID_NS)) | |
| # <manifest package="..." versionCode="1" versionName="1.0"> | |
| 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) | |
| # <uses-sdk minSdk="21" targetSdk="34"/> | |
| 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") | |
| # <uses-permission> 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") | |
| # <application label="..." allowBackup="true" hardwareAccelerated="true" usesCleartextTraffic="true"> | |
| 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) | |
| # <activity name="...MainActivity" exported="true" configChanges="0x6A0"> | |
| 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) | |
| # <intent-filter> | |
| xml_chunks += start_tag_chunk("","intent-filter",b"") | |
| # <action name="android.intent.action.MAIN"/> | |
| xml_chunks += start_tag_chunk("","action", string_attr(True,"name","android.intent.action.MAIN")) | |
| xml_chunks += end_tag_chunk("","action") | |
| # <category name="android.intent.category.LAUNCHER"/> | |
| 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('<HHI', 0x0003, 8, file_size) + inner | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PNG HELPERS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def make_png(color_hex: str, size=96) -> 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(""" | |
| <div class="header"> | |
| <div class="badge">π€ Hugging Face Space</div> | |
| <h1>π± WebToAPK Builder</h1> | |
| <p>Convierte cualquier sitio web en una APK <strong>real e instalable</strong> de Android.<br> | |
| Genera bytecode DEX autΓ©ntico + firma JAR. Sin Android Studio.</p> | |
| </div> | |
| """) | |
| 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(""" | |
| <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.<br> | |
| 2. TransfiΓ©relo a tu Android (cable, WhatsApp, Drive, etc.).<br> | |
| 3. <em>Ajustes β Seguridad β Fuentes desconocidas</em> β activa.<br> | |
| 4. Abre el APK y toca <strong>Instalar</strong>.<br><br> | |
| <strong>β APK real:</strong> Genera bytecode DEX compilado + firma JAR vΓ‘lida. | |
| Requiere internet en el dispositivo para cargar el sitio web. | |
| </div> | |
| """) | |
| 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) | |