EasyAppShell/auto_package.py
Tatta 2e93f8c282 🎉 初始化 EasyAppShell — 配置化 Android WebView 壳项目
- 配置驱动的 WebView App 壳 (Kotlin)
- auto_package.py 一键打包脚本(改 URL/名称/图标/包名)
- 完整 WebView 功能:JS/DOM存储/定位/下拉刷新/进度条
- JS↔Native 桥接 (Toast/分享/剪贴板)
- 自适应图标 + 多分辨率图标支持
- Gradle 8.10 + AGP 8.7.3 + Kotlin 2.1.0
2026-07-02 22:52:37 +08:00

490 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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'<string name="app_name">.*?</string>',
f'<string name="app_name">{app_name}</string>',
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"""<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
"""
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()