#!/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()