| """ |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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"), |
| } |
|
|
| |
| |
| |
|
|
| 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 |
|
|
| |
| _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. |
| """ |
| |
| if len(url) > _URL_SLOT_LEN: |
| url = url[:_URL_SLOT_LEN] |
| padded_url = url + " " * (_URL_SLOT_LEN - len(url)) |
|
|
| |
| 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, |
| "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) |
|
|
| |
| 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()) |
|
|
| |
| 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() |
|
|
| |
| 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))) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
|
|
| |
| |
| |
|
|
| ANDROID_NS = "http://schemas.android.com/apk/res/android" |
|
|
| |
| _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" |
| |
| |
| str_list = [] |
| def add(s): |
| if s not in str_list: str_list.append(s) |
| return str_list.index(s) |
|
|
| |
| ns_prefix_idx = add("") |
| ns_uri_idx = add(ANDROID_NS) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| def ns_chunk(chunk_type, prefix_idx, uri_idx): |
| |
| body = struct.pack('<IIII', 1, 0xFFFFFFFF, prefix_idx, uri_idx) |
| return struct.pack('<HHI', chunk_type, 16, 8 + len(body)) + body |
|
|
| TYPE_STRING = 0x03 |
| TYPE_INT = 0x10 |
| TYPE_BOOL = 0x12 |
|
|
| def attr(ns_idx, name_idx, value_str_idx, value_type, value_data): |
| |
| 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): |
| |
| |
| ns = si(ns_str) if ns_str else 0xFFFFFFFF |
| attr_count = len(attrs_data) // 20 |
| body = struct.pack('<IIIIHHHHHH', |
| 1, |
| 0xFFFFFFFF, |
| ns, si(tag_name), |
| 20, |
| 20, |
| attr_count, |
| 0, 0, 0 |
| ) + attrs_data |
| return struct.pack('<HHI', 0x0102, 16, 8 + len(body)) + body |
|
|
| def end_tag_chunk(ns_str, tag_name): |
| |
| 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 |
|
|
| |
| xml_chunks = bytearray() |
|
|
| |
| xml_chunks += ns_chunk(0x0100, si(""), si(ANDROID_NS)) |
|
|
| |
| 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 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 |
| 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") |
|
|
| |
| xml_chunks += ns_chunk(0x0101, si(""), si(ANDROID_NS)) |
|
|
| |
| inner = bytes(string_pool) + bytes(resource_chunk) + bytes(xml_chunks) |
| file_size = 8 + len(inner) |
| return struct.pack('<HHI', 0x0003, 8, file_size) + inner |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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: |
| |
| 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()) |
| ) |
| |
| 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') |
|
|
| |
| 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") |
|
|
| |
| 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") |
|
|
| |
| 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()) |
|
|
| |
| 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() |
|
|
|
|
| |
| |
| |
|
|
| 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" |
|
|
| |
| 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, |
| ) |
|
|
| |
| with zipfile.ZipFile(apk_unsigned, "w") as zf: |
| |
| zf.writestr(zipfile.ZipInfo("AndroidManifest.xml"), manifest_bytes, |
| compress_type=zipfile.ZIP_STORED) |
|
|
| |
| zf.writestr(zipfile.ZipInfo("classes.dex"), dex_bytes, |
| compress_type=zipfile.ZIP_STORED) |
|
|
| |
| 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, |
| ) |
|
|
| |
| 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) |
|
|
| |
| unsigned_bytes = apk_unsigned.read_bytes() |
| signed_bytes = _jar_sign(unsigned_bytes) |
| apk_signed.write_bytes(signed_bytes) |
|
|
| return apk_signed |
|
|
|
|
| |
| |
| |
|
|
| 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]: |
|
|
| |
| 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) |
|
|
|
|
| |
| |
| |
| |
|
|
| 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(""" |
| <div class="header-box"> |
| <h1>📱 WebToAPK Builder</h1> |
| <p>Convierte cualquier sitio web en una APK <strong>real e instalable</strong> de Android.<br> |
| DEX bytecode + AndroidManifest AXML binario + firma JAR. Sin Android Studio.</p> |
| </div> |
| """) |
|
|
| with gr.Row(): |
| |
| 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") |
|
|
| |
| 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**.*" |
| ) |
|
|
| |
| 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(""" |
| <div class="info-box"> |
| <strong>📌 ¿Cómo instalar?</strong><br> |
| 1. Descarga el APK · 2. Pásalo al Android (Drive, cable, etc.)<br> |
| 3. <em>Ajustes → Seguridad → Fuentes desconocidas</em> → activar<br> |
| 4. Abre el APK y toca <strong>Instalar</strong><br><br> |
| Requiere conexión a internet en el dispositivo para cargar el sitio. |
| minSdkVersion 21 (Android 5.0+). |
| </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) |
|
|