- 配置驱动的 WebView App 壳 (Kotlin) - auto_package.py 一键打包脚本(改 URL/名称/图标/包名) - 完整 WebView 功能:JS/DOM存储/定位/下拉刷新/进度条 - JS↔Native 桥接 (Toast/分享/剪贴板) - 自适应图标 + 多分辨率图标支持 - Gradle 8.10 + AGP 8.7.3 + Kotlin 2.1.0
490 lines
16 KiB
Python
490 lines
16 KiB
Python
#!/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()
|