#!/usr/bin/env python3 """ EasyAppShell 自动打包工具 ========================= 一键修改 URL、App 名称、图标,然后重新打包生成 APK。 用法: python auto_package.py --url https://your-site.com --name "我的 App" --icon icon.png 完整参数: --url WebView 加载的主 URL (必填) --name App 显示名称 (必填) --icon 图标文件路径 (PNG/JPEG, 建议 1024x1024, 可选) --package Android 包名 (可选, 默认 com.easyappshell.自定义) --output APK 输出目录 (可选, 默认 dist/) --http 允许 HTTP 混合内容 (可选, 默认仅 HTTPS) --portrait 锁定竖屏 (可选) --landscape 锁定横屏 (可选) --version App 版本号, 如 "1.0.0" (可选) --build Build 编号 (可选, 默认 1) --release 构建 Release APK (可选, 需要 keystore) --keystore Keystore 文件路径 (--release 时需要) --keystore-pass Keystore 密码 --key-alias Key 别名 --key-pass Key 密码 --no-build 只生成配置文件,不执行构建 示例: python auto_package.py --url https://mytool.com --name "我的工具" --icon myicon.png python auto_package.py --url https://shop.com --name "商城" --portrait python auto_package.py --url https://app.com --name "正式版" --release \\ --keystore my.keystore --keystore-pass 123456 --key-alias mykey """ import argparse import json import os import platform import re import shutil import subprocess import sys import tempfile import uuid from pathlib import Path # ===================== 路径常量 ===================== PROJECT_ROOT = Path(__file__).resolve().parent ASSETS_DIR = PROJECT_ROOT / "app" / "src" / "main" / "assets" RES_DIR = PROJECT_ROOT / "app" / "src" / "main" / "res" VALUES_DIR = RES_DIR / "values" MIPMAP_DIRS = [ "mipmap-mdpi", # 48x48 "mipmap-hdpi", # 72x72 "mipmap-xhdpi", # 96x96 "mipmap-xxhdpi", # 144x144 "mipmap-xxxhdpi", # 192x192 ] MIPMAP_SIZES = [48, 72, 96, 144, 192] CONFIG_FILE = ASSETS_DIR / "config.json" STRINGS_FILE = VALUES_DIR / "strings.xml" MANIFEST_FILE = PROJECT_ROOT / "app" / "src" / "main" / "AndroidManifest.xml" FOREGROUND_ICON = RES_DIR / "drawable" / "ic_launcher_foreground.xml" BACKGROUND_ICON = RES_DIR / "drawable" / "ic_launcher_background.xml" # ===================== 工具函数 ===================== def info(msg: str): print(f"[INFO] {msg}") def warn(msg: str): print(f"[WARN] {msg}") def error(msg: str, exit_code: int = 1): print(f"[ERROR] {msg}") sys.exit(exit_code) def check_dependencies(): """检查必要的工具是否存在""" # Python 3.6+ if sys.version_info < (3, 6): error("需要 Python 3.6+") # Pillow (用于图片处理) try: from PIL import Image return Image except ImportError: warn("Pillow 未安装,图标处理功能受限。") warn("请运行: pip install Pillow") warn("将使用默认图标继续...") return None # Gradle / Java # 这些在 build 时检查 def read_json(path: Path) -> dict: with open(path, "r", encoding="utf-8") as f: return json.load(f) def write_json(path: Path, data: dict): with open(path, "w", encoding="utf-8", newline="\n") as f: json.dump(data, f, indent=2, ensure_ascii=False) f.write("\n") def read_file(path: Path) -> str: with open(path, "r", encoding="utf-8") as f: return f.read() def write_file(path: Path, content: str): with open(path, "w", encoding="utf-8", newline="\n") as f: f.write(content) def sanitize_package_name(name: str) -> str: """将名称转为合法的 Android 包名""" # 只保留字母数字和下划线 safe = re.sub(r"[^a-zA-Z0-9_]", "_", name.lower().strip()) safe = re.sub(r"_+", "_", safe) safe = safe.strip("_") if not safe or safe[0].isdigit(): safe = "app_" + safe return safe # ===================== 核心功能 ===================== def update_config(url: str, name: str, args: argparse.Namespace): """更新 config.json""" config = read_json(CONFIG_FILE) config["main_url"] = url config["app_name"] = name if args.http: config["allow_http"] = True if args.portrait: config["orientation"] = "portrait" elif args.landscape: config["orientation"] = "landscape" write_json(CONFIG_FILE, config) info(f"✅ config.json 已更新: main_url={url}, app_name={name}") def update_strings(app_name: str): """更新 strings.xml 中的 app_name""" content = read_file(STRINGS_FILE) # 替换 app_name new_content = re.sub( r'.*?', f'{app_name}', content, ) if new_content == content: warn("⚠️ 未能替换 strings.xml 中的 app_name,手动检查") write_file(STRINGS_FILE, new_content) info(f"✅ strings.xml 已更新: app_name={app_name}") def update_package_name(new_package: str, args: argparse.Namespace): """更新 Android 包名""" # 验证包名格式 if not re.match(r'^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$', new_package): error(f"❌ 无效的包名: {new_package}") # 1. 更新 AndroidManifest.xml manifest = read_file(MANIFEST_FILE) manifest = re.sub( r'package="[^"]*"', f'package="{new_package}"', manifest, ) write_file(MANIFEST_FILE, manifest) # 2. 更新 build.gradle.kts 中的 namespace 和 applicationId build_gradle = PROJECT_ROOT / "app" / "build.gradle.kts" content = read_file(build_gradle) content = re.sub( r'namespace\s*=\s*"[^"]*"', f'namespace = "{new_package}"', content, ) content = re.sub( r'applicationId\s*=\s*"[^"]*"', f'applicationId = "{new_package}"', content, ) # 移除 applicationIdSuffix content = re.sub( r'.*applicationIdSuffix.*\n', '', content, ) write_file(build_gradle, content) # 3. 移动 Java/Kotlin 源文件到新包名目录 old_package = "com/easyappshell" new_package_path = new_package.replace(".", "/") src_dir = PROJECT_ROOT / "app" / "src" / "main" / "java" old_dir = src_dir / old_package new_dir = src_dir / new_package_path if old_dir.exists() and old_dir != new_dir: new_dir.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(old_dir, new_dir, dirs_exist_ok=True) shutil.rmtree(old_dir) # 删除空的父目录 parts = old_package.split("/") for i in range(len(parts), 0, -1): p = src_dir / "/".join(parts[:i]) if p.exists() and not any(p.iterdir()): p.rmdir() info(f"✅ 源代码已从 {old_package} 移动到 {new_package_path}") # 更新所有 Kotlin 文件中的 package 声明 for kt_file in new_dir.rglob("*.kt"): kt_content = read_file(kt_file) kt_content = re.sub( r'^package\s+com\.easyappshell', f'package {new_package}', kt_content, ) write_file(kt_file, kt_content) info(f"✅ 包名已更新: {new_package}") def process_icon(icon_path: str, Image_module): """处理 App 图标:生成各分辨率 PNG,并更新 adaptive icon""" icon_file = Path(icon_path) if not icon_file.exists(): error(f"❌ 图标文件不存在: {icon_path}") # 打开图片 img = Image_module.open(icon_file).convert("RGBA") # 为旧式 mipmap 目录生成各尺寸 PNG for dir_name, size in zip(MIPMAP_DIRS, MIPMAP_SIZES): out_dir = RES_DIR / dir_name out_dir.mkdir(exist_ok=True) resized = img.resize((size, size), Image_module.LANCZOS) out_path = out_dir / "ic_launcher.png" resized.save(out_path, "PNG") # 同时也保存 round 版本 round_path = out_dir / "ic_launcher_round.png" resized.save(round_path, "PNG") info(f" 📱 {dir_name}/{ic_launcher.png} ({size}x{size})") # 对于 adaptive icon (API 26+),我们需要生成 foreground 和 background # 生成纯色背景 bg_color = (33, 150, 243, 255) # 默认蓝色 bg_img = Image_module.new("RGBA", (108, 108), bg_color) bg_path = RES_DIR / "drawable" / "ic_launcher_background.png" bg_img.save(bg_path, "PNG") info(f" 🎨 自适应图标背景已生成") # 将图标作为 foreground(缩放到安全区域 72dp 内) fg_size = 72 fg_img = img.resize((fg_size, fg_size), Image_module.LANCZOS) # 居中放在 108x108 的画布上 canvas = Image_module.new("RGBA", (108, 108), (0, 0, 0, 0)) x = (108 - fg_size) // 2 y = (108 - fg_size) // 2 canvas.paste(fg_img, (x, y), fg_img) fg_path = RES_DIR / "drawable" / "ic_launcher_foreground.png" canvas.save(fg_path, "PNG") info(f" 🎨 自适应图标前景已生成") # 更新 adaptive icon XML 引用 PNG 而非 vector _update_adaptive_icon_xml("ic_launcher.xml") _update_adaptive_icon_xml("ic_launcher_round.xml") info(f"✅ 图标已更新: {icon_path}") def _update_adaptive_icon_xml(filename: str): """更新 adaptive-icon XML 以引用 PNG drawable""" path = RES_DIR / "mipmap-anydpi-v26" / filename content = f""" """ write_file(path, content) def update_version(version: str, build_number: int): """更新版本号""" build_gradle = PROJECT_ROOT / "app" / "build.gradle.kts" content = read_file(build_gradle) content = re.sub( r'versionName\s*=\s*"[^"]*"', f'versionName = "{version}"', content, ) content = re.sub( r'versionCode\s*=\s*\d+', f'versionCode = {build_number}', content, ) write_file(build_gradle, content) info(f"✅ 版本已更新: {version} (build {build_number})") def run_build(args: argparse.Namespace) -> Path: """执行 Gradle 构建""" info("🚀 开始构建 APK...") # 确定 Gradle 命令 if platform.system() == "Windows": gradle_cmd = [str(PROJECT_ROOT / "gradlew.bat")] else: gradle_cmd = ["bash", str(PROJECT_ROOT / "gradlew")] # 选择构建类型 if args.release: task = "assembleRelease" else: task = "assembleDebug" # 执行构建 info(f" 运行: {' '.join(gradle_cmd)} {task}") result = subprocess.run( gradle_cmd + [task], cwd=str(PROJECT_ROOT), capture_output=False, ) if result.returncode != 0: error(f"❌ 构建失败 (exit code {result.returncode}),请检查上方错误信息") # 找到构建产物 build_type = "release" if args.release else "debug" apk_pattern = f"app-{build_type}.apk" apk_dir = PROJECT_ROOT / "app" / "build" / "outputs" / "apk" / build_type apk_path = apk_dir / apk_pattern if not apk_path.exists(): # 尝试查找其他 APK candidates = list(apk_dir.glob("*.apk")) if candidates: apk_path = candidates[0] else: error(f"❌ 找不到构建产物: {apk_dir}") # 复制到输出目录 output_dir = Path(args.output) output_dir.mkdir(exist_ok=True) app_name_slug = sanitize_package_name(args.name) output_name = f"{app_name_slug}-{args.version}.apk" output_path = output_dir / output_name shutil.copy2(apk_path, output_path) info(f"📦 APK 已生成: {output_path}") return output_path # ===================== 主入口 ===================== def main(): parser = argparse.ArgumentParser( description="EasyAppShell 自动打包工具 — 改 URL、改名字、换图标,一键生成新 App!", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" 示例: python auto_package.py --url https://mytool.com --name "我的工具" --icon icon.png python auto_package.py --url https://shop.com --name "商城" --portrait --http python auto_package.py --url https://app.com --name "正式版" \\ --release --keystore my.keystore --keystore-pass 123456 --key-alias mykey python auto_package.py --url https://test.com --name "测试" --no-build """, ) parser.add_argument("--url", required=True, help="WebView 加载的主 URL (必填)") parser.add_argument("--name", required=True, help="App 显示名称 (必填)") parser.add_argument("--icon", help="图标文件路径 (PNG/JPEG, 建议 1024x1024)") parser.add_argument("--package", help="Android 包名 (如 com.example.myapp)") parser.add_argument("--output", default="dist", help="APK 输出目录 (默认 dist/)") parser.add_argument("--http", action="store_true", help="允许 HTTP 混合内容") parser.add_argument("--portrait", action="store_true", help="锁定竖屏") parser.add_argument("--landscape", action="store_true", help="锁定横屏") parser.add_argument("--version", default="1.0.0", help="App 版本号 (默认 1.0.0)") parser.add_argument("--build", type=int, default=1, help="Build 编号 (默认 1)") parser.add_argument("--release", action="store_true", help="构建 Release APK") parser.add_argument("--keystore", help="Keystore 文件路径 (Release 时需要)") parser.add_argument("--keystore-pass", help="Keystore 密码") parser.add_argument("--key-alias", help="Key 别名") parser.add_argument("--key-pass", help="Key 密码") parser.add_argument("--no-build", action="store_true", help="只生成配置文件,不执行构建") args = parser.parse_args() print("=" * 60) print(" EasyAppShell 自动打包工具") print("=" * 60) # 检查依赖 Image_module = check_dependencies() # 1. 生成包名(如果未指定) if not args.package: slug = sanitize_package_name(args.name) args.package = f"com.easyappshell.{slug}" info(f"📝 自动生成包名: {args.package}") # 2. 更新配置 update_config(args.url, args.name, args) update_strings(args.name) update_package_name(args.package, args) update_version(args.version, args.build) # 3. 处理图标 if args.icon: if Image_module: process_icon(args.icon, Image_module) else: warn("⚠️ 未安装 Pillow,跳过图标处理。") warn(" 安装: pip install Pillow") else: info("🔲 未指定图标,使用默认图标") # 4. 更新 gradle 的 keystore 配置(如果是 release 构建) if args.release: if not args.keystore: error("❌ Release 构建需要 --keystore 参数") if not args.keystore_pass: error("❌ Release 构建需要 --keystore-pass 参数") if not args.key_alias: error("❌ Release 构建需要 --key-alias 参数") build_gradle = PROJECT_ROOT / "app" / "build.gradle.kts" content = read_file(build_gradle) signing_config = f""" signingConfigs {{ release {{ storeFile = file("{args.keystore}") storePassword = "{args.keystore_pass}" keyAlias = "{args.key_alias}" keyPassword = "{args.key_pass or args.keystore_pass}" }} }} """ # 在 buildTypes 之前插入 signingConfigs content = content.replace( " buildTypes {", f"{signing_config}\n buildTypes {{", ) # 在 release 中引用 signingConfig content = content.replace( "release {", f"""release {{ signingConfig = signingConfigs.release""", ) write_file(build_gradle, content) info("✅ Release 签名配置已添加") # 5. 构建 if args.no_build: info("\n✅ 配置文件已生成完毕 (--no-build 模式)") info(f" 运行以下命令手动构建:") if platform.system() == "Windows": info(f" {PROJECT_ROOT}\\gradlew.bat assembleDebug") else: info(f" {PROJECT_ROOT}/gradlew assembleDebug") else: apk_path = run_build(args) print("\n" + "=" * 60) print(f" 🎉 打包完成!") print(f" 📦 APK 位置: {apk_path}") print("=" * 60) # 恢复 strings.xml 中可能被误改的内容(确保一致性) info("✨ 完成!") if __name__ == "__main__": main()