🎉 初始化 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
This commit is contained in:
Tatta 2026-07-02 22:52:37 +08:00
commit 2e93f8c282
38 changed files with 1823 additions and 0 deletions

32
.gitignore vendored Normal file
View File

@ -0,0 +1,32 @@
# EasyAppShell .gitignore
# Gradle
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
# IDE
.idea/
*.iml
.DS_Store
Thumbs.db
# 构建产物
*.apk
*.aab
*.ap_
*.dex
# 本地配置文件
local.properties
*.keystore
*.jks
# 输出目录
dist/
# Python
__pycache__/
*.pyc
*.pyo
*.egg-info/

210
README.md Normal file
View File

@ -0,0 +1,210 @@
# 📱 EasyAppShell
**一个极简的配置化 Android WebView 壳项目。**
把你任何移动端 Web 项目打包成一个真正的 Android App —— 改改 URL、换个图标、改个名字重新编译就是一个全新的 App。
---
## ✨ 特性
- **纯 WebView 壳** — 体积小、速度快,只有一个 Activity
- **配置驱动** — 所有设置都在 `config.json` 里,改配置 = 改 App
- **一键打包**`auto_package.py` + 改 URL、改名字、换图标一步到位
- **JS↔Native 桥接** — 前端可以调 Toast、分享、剪贴板等原生能力
- **完整 WebView 功能** — JavaScript、DOM 存储、定位、下拉刷新、进度条
- **自适应图标** — 支持 Android 8+ 自适应图标
- **安全配置** — 默认仅 HTTPS支持白名单/黑名单
---
## 🚀 一分钟快速开始
### 前置条件
| 工具 | 说明 |
|------|------|
| **Java 17+** | JDK用于编译 Android 代码 |
| **Android SDK** | 编译 Android App 所需 |
| **Python 3.6+** | 运行自动打包脚本 |
| **Pillow** (可选) | `pip install Pillow`,用于自动处理图标 |
设置 Android SDK 环境变量(根据你安装的路径):
```bash
# Windows
set ANDROID_HOME=C:\Users\你的用户名\AppData\Local\Android\Sdk
# macOS / Linux
export ANDROID_HOME=~/Library/Android/sdk
```
> **没有 Android SDK** 安装 [Android Studio](https://developer.android.com/studio),打开 SDK Manager 安装 SDK 35。
### 打包你的第一个 App
```bash
# 1. 进入项目目录
cd EasyAppShell
# 2. 一键打包(改 URL + 名称 + 图标)
python auto_package.py \
--url https://你的网站.com \
--name "我的第一个 App" \
--icon /path/to/icon.png
# 3. 在 dist/ 目录下找到 APK安装到手机
```
### 仅改配置不构建
```bash
python auto_package.py --url https://你的网站.com --name "测试版" --no-build
# 然后手动:
./gradlew assembleDebug
```
---
## 📖 完整参数说明
### `auto_package.py`
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | ✅ | WebView 加载的主 URL |
| `--name` | ✅ | App 显示名称 |
| `--icon` | ❌ | 图标文件路径 (PNG/JPEG, 建议 1024×1024) |
| `--package` | ❌ | Android 包名 (默认: `com.easyappshell.{name}`) |
| `--output` | ❌ | APK 输出目录 (默认: `dist/`) |
| `--http` | ❌ | 允许 HTTP 混合内容 |
| `--portrait` | ❌ | 锁定竖屏 |
| `--landscape` | ❌ | 锁定横屏 |
| `--version` | ❌ | App 版本号 (默认: `1.0.0`) |
| `--build` | ❌ | Build 编号 (默认: 1) |
| `--release` | ❌ | 构建 Release 版(需要签名) |
| `--keystore` | ❌ | Keystore 文件路径 |
| `--keystore-pass` | ❌ | Keystore 密码 |
| `--key-alias` | ❌ | Key 别名 |
| `--key-pass` | ❌ | Key 密码 |
| `--no-build` | ❌ | 只生成配置文件,不构建 |
### 示例
```bash
# 最简用法
python auto_package.py --url https://mytool.com --name "我的工具"
# 带自定义图标、锁定竖屏
python auto_package.py --url https://shop.com --name "商城" \
--icon ~/icons/shop.png --portrait
# Release 构建(需签名)
python auto_package.py --url https://app.com --name "正式版" \
--release --keystore release.keystore --keystore-pass 123456 --key-alias mykey
# 多个 App 共存(不同包名)
python auto_package.py --url https://app1.com --name "应用一" --package com.example.app1
python auto_package.py --url https://app2.com --name "应用二" --package com.example.app2
```
---
## ⚙️ 手动配置config.json
你也可以直接编辑 `app/src/main/assets/config.json` 来精细调控:
```json
{
"app_name": "EasyAppShell",
"main_url": "https://example.com",
"enable_js": true,
"enable_dom_storage": true,
"status_bar_color": "#2196F3",
"enable_swipe_refresh": true,
"orientation": "unspecified",
"allowed_origin_whitelist": ["*"],
"blocked_hosts": [],
"extra_http_headers": {}
}
```
全部配置项说明见文件内的注释。
---
## 🌐 JS → Native 桥接
前端页面可以通过 `window.NativeBridge` 调用原生功能:
```javascript
// 弹出 Toast
window.NativeBridge.showToast("操作成功");
// 分享链接
window.NativeBridge.shareUrl("标题", "https://example.com");
// 复制到剪贴板
window.NativeBridge.copyToClipboard("要复制的内容");
// 获取 App 版本
var version = window.NativeBridge.getAppVersion();
```
此功能可在 config.json 中关闭:`"allow_javascript_interface": false`
---
## 🏗 项目结构
```
EasyAppShell/
├── auto_package.py # ★ 一键打包脚本
├── app/
│ ├── build.gradle.kts # App 构建配置
│ └── src/main/
│ ├── assets/
│ │ ├── config.json # ★ 核心配置文件
│ │ └── error.html # 网络错误页
│ ├── java/.../
│ │ ├── MainActivity.kt # 主 Activity
│ │ ├── WebAppConfig.kt # 配置解析
│ │ └── NativeBridge.kt # JS↔Native 桥接
│ ├── res/ # 资源文件
│ └── AndroidManifest.xml # 清单文件
├── build.gradle.kts # 顶层构建
├── settings.gradle.kts
└── gradlew / gradlew.bat # Gradle Wrapper
```
---
## ❓ 常见问题
**Q: 打包出来的 APK 有多大?**
A: Debug 版约 3-5MBRelease 版约 2-3MB经过 ProGuard 压缩)。
**Q: 如何生成 Release 签名?**
A: 使用 Android Studio 或 `keytool` 生成 keystore
```bash
keytool -genkey -v -keystore my-release-key.keystore \
-alias my-key-alias -keyalg RSA -keysize 2048 -validity 10000
```
**Q: 可以支持多个页面/导航吗?**
A: 这是个 WebView 壳,路由交给你的 Web 项目处理。WebView 支持返回键后退。
**Q: 为什么安装后 App 名称还是 EasyAppShell**
A: 你可能遗漏了 `--name` 参数,或需要重新运行打包脚本。
**Q: 我修改了 config.json 但构建后没生效?**
A: 检查 `assets/config.json` 的路径是否正确,然后 clean 再构建:
```bash
./gradlew clean assembleDebug
```
---
## 📄 License
MIT

52
app/build.gradle.kts Normal file
View File

@ -0,0 +1,52 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "com.easyappshell.memos"
compileSdk = 36
defaultConfig {
applicationId = "com.easyappshell.memos"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0.0"
}
buildFeatures {
buildConfig = true
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.webkit)
implementation(libs.androidx.appcompat)
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
}

10
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,10 @@
# ProGuard rules for EasyAppShell
# WebView 相关
-keepclassmembers class * extends android.webkit.WebViewClient {
public void *(android.webkit.WebView, java.lang.String, android.graphics.Bitmap);
public boolean *(android.webkit.WebView, java.lang.String);
}
-keepclassmembers class * extends android.webkit.WebChromeClient {
public void *(android.webkit.WebView, java.lang.String);
}
-dontwarn com.easyappshell.**

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 网络权限 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- 可选权限(根据 config.json 启用) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.EasyAppShell"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- 支持深度链接:通过 URL 启动并加载指定链接 -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="*" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,31 @@
{
"app_name": "Memos",
"main_url": "https://memo.pags.cn",
"load_error_page": "file:///android_asset/error.html",
"enable_js": true,
"enable_dom_storage": true,
"enable_file_access": false,
"allow_javascript_interface": true,
"support_multiple_windows": false,
"user_agent_suffix": "EasyAppShell",
"status_bar_color": "#2196F3",
"status_bar_light_icons": true,
"orientation": "unspecified",
"show_loading_progress": true,
"enable_swipe_refresh": true,
"clear_cache_on_start": false,
"allow_http": false,
"allow_content_url": false,
"enable_geolocation": true,
"geolocation_api_host": "",
"extra_http_headers": {},
"allowed_origin_whitelist": [
"*"
],
"blocked_hosts": [],
"pull_to_refresh_colors": [
"#2196F3",
"#03A9F4",
"#00BCD4"
]
}

View File

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>加载失败</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #f5f5f5;
color: #333;
padding: 20px;
}
.container {
text-align: center;
max-width: 360px;
}
.icon {
font-size: 64px;
margin-bottom: 16px;
}
h1 {
font-size: 20px;
margin-bottom: 8px;
}
p {
font-size: 14px;
color: #666;
line-height: 1.6;
margin-bottom: 24px;
}
.btn {
display: inline-block;
padding: 12px 32px;
background: #2196F3;
color: #fff;
border: none;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
text-decoration: none;
}
.btn:active {
opacity: 0.8;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">📡</div>
<h1>网络开小差了</h1>
<p>页面加载失败,请检查网络连接后重试。</p>
<button class="btn" onclick="reloadPage()">重新加载</button>
</div>
<script>
function reloadPage() {
if (window.NativeBridge && NativeBridge.reloadPage) {
NativeBridge.reloadPage();
} else {
// fallback: 通过 Android 回调重新加载
document.location.reload();
}
}
</script>
</body>
</html>

View File

@ -0,0 +1,335 @@
package com.easyappshell.memos
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.ActivityInfo
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Message
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.webkit.GeolocationPermissions
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
class MainActivity : AppCompatActivity() {
private lateinit var config: AppConfig
private lateinit var webView: WebView
private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var progressBar: ProgressBar
private lateinit var errorView: TextView
private lateinit var webViewContainer: FrameLayout
private var pageLoadTimeout: Handler? = null
private val pageLoadTimeoutRunnable = Runnable {
showError(-1, "连接超时,请检查网络后重试")
}
override fun onCreate(savedInstanceState: Bundle?) {
pageLoadTimeout = Handler(Looper.getMainLooper())
super.onCreate(savedInstanceState)
config = AppConfig.load(this)
title = config.appName
setOrientation(config.orientation)
setupStatusBar()
setContentView(R.layout.activity_main)
initViews()
setupWebView()
setupErrorViewRetry()
loadMainUrl()
}
@SuppressLint("SetJavaScriptEnabled")
private fun initViews() {
webViewContainer = findViewById(R.id.webViewContainer)
swipeRefresh = findViewById(R.id.swipeRefresh)
progressBar = findViewById(R.id.progressBar)
errorView = findViewById(R.id.errorView)
webView = findViewById(R.id.webView)
swipeRefresh.isEnabled = config.enableSwipeRefresh
swipeRefresh.setOnRefreshListener { webView.reload() }
if (config.pullToRefreshColors.isNotEmpty()) {
val colorInts = config.pullToRefreshColors.map { Color.parseColor(it) }.toIntArray()
swipeRefresh.setColorSchemeColors(*colorInts)
}
progressBar.visibility = if (config.showLoadingProgress) View.VISIBLE else View.GONE
ContextCompat.getDrawable(this, android.R.color.holo_blue_bright)?.let {
progressBar.indeterminateDrawable = it
}
}
@SuppressLint("SetJavaScriptEnabled")
private fun setupWebView() {
val settings = webView.settings
settings.javaScriptEnabled = config.enableJs
settings.domStorageEnabled = config.enableDomStorage
settings.allowFileAccess = config.enableFileAccess
settings.allowContentAccess = config.allowContentUrl
settings.javaScriptCanOpenWindowsAutomatically = config.supportMultipleWindows
settings.setSupportMultipleWindows(config.supportMultipleWindows)
settings.loadWithOverviewMode = true
settings.useWideViewPort = true
settings.builtInZoomControls = true
settings.displayZoomControls = false
settings.cacheMode = WebSettings.LOAD_DEFAULT
settings.mixedContentMode = if (config.allowHttp)
WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
else
WebSettings.MIXED_CONTENT_NEVER_ALLOW
if (WebViewFeature.isFeatureSupported(WebViewFeature.START_SAFE_BROWSING)) {
WebViewCompat.startSafeBrowsing(this, object : ValueCallback<Boolean> {
override fun onReceiveValue(value: Boolean?) {}
})
}
if (config.userAgentSuffix.isNotBlank()) {
val ua = webView.settings.userAgentString
if (!ua.contains(config.userAgentSuffix)) {
webView.settings.userAgentString = "$ua ${config.userAgentSuffix}"
}
}
settings.mediaPlaybackRequiresUserGesture = true
if (config.allowedOriginWhitelist.isNotEmpty()) {
val origins = config.allowedOriginWhitelist.toTypedArray()
if (WebViewFeature.isFeatureSupported(WebViewFeature.SAFE_BROWSING_ALLOWLIST)) {
WebViewCompat.setSafeBrowsingAllowlist(origins.toSet(), object : ValueCallback<Boolean> {
override fun onReceiveValue(value: Boolean?) {}
})
}
}
if (config.clearCacheOnStart) {
webView.clearCache(true)
webView.clearHistory()
}
webView.webViewClient = EasyWebViewClient()
webView.webChromeClient = EasyChromeClient()
if (config.allowJavascriptInterface) {
webView.addJavascriptInterface(NativeBridge(this), "NativeBridge")
}
}
private fun loadMainUrl() {
val urlToLoad = intent?.dataString ?: config.mainUrl
if (urlToLoad.isNotBlank()) {
startPageLoadTimeout()
webView.loadUrl(urlToLoad, config.extraHttpHeaders)
}
}
private fun startPageLoadTimeout() {
pageLoadTimeout?.removeCallbacks(pageLoadTimeoutRunnable)
pageLoadTimeout?.postDelayed(pageLoadTimeoutRunnable, 15000L)
}
private fun cancelPageLoadTimeout() {
pageLoadTimeout?.removeCallbacks(pageLoadTimeoutRunnable)
}
private fun showError(code: Int, description: String) {
swipeRefresh.isRefreshing = false
progressBar.visibility = View.GONE
webView.visibility = View.GONE
webViewContainer.visibility = View.GONE
errorView.visibility = View.VISIBLE
errorView.text = getString(R.string.error_loading_page, code, description)
}
private fun setupErrorViewRetry() {
errorView.setOnClickListener {
retryLoad()
}
}
private fun retryLoad() {
errorView.visibility = View.GONE
webView.visibility = View.VISIBLE
webViewContainer.visibility = View.VISIBLE
loadMainUrl()
}
private fun setOrientation(orientation: String) {
requestedOrientation = when (orientation.lowercase()) {
"portrait" -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
"landscape" -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
"sensor_portrait" -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
"sensor_landscape" -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
private fun setupStatusBar() {
val colorInt = try {
Color.parseColor(config.statusBarColor)
} catch (_: Exception) {
Color.parseColor("#2196F3")
}
window.statusBarColor = colorInt
WindowCompat.getInsetsController(window, window.decorView).also {
it.isAppearanceLightStatusBars = !config.statusBarLightIcons
}
ViewCompat.setOnApplyWindowInsetsListener(findViewById(android.R.id.content)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
}
inner class EasyWebViewClient : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
cancelPageLoadTimeout()
errorView.visibility = View.GONE
webView.visibility = View.VISIBLE
webViewContainer.visibility = View.VISIBLE
if (config.showLoadingProgress) progressBar.visibility = View.VISIBLE
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
cancelPageLoadTimeout()
swipeRefresh.isRefreshing = false
if (config.showLoadingProgress) progressBar.visibility = View.GONE
}
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
cancelPageLoadTimeout()
if (request?.isForMainFrame == true) {
val errorCode = error?.errorCode ?: -1
val description = error?.description?.toString() ?: "未知错误"
showError(errorCode, description)
}
}
override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, errorResponse: android.webkit.WebResourceResponse?) {
if (request?.isForMainFrame == true) {
cancelPageLoadTimeout()
val statusCode = errorResponse?.statusCode ?: -1
val reasonPhrase = errorResponse?.reasonPhrase ?: "HTTP Error"
showError(statusCode, "$statusCode $reasonPhrase")
}
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val url = request?.url?.toString() ?: return false
request?.url?.host?.let { host ->
if (config.blockedHosts.any { host.contains(it, ignoreCase = true) }) return true
}
return when {
url.startsWith("tel:") || url.startsWith("mailto:") ||
url.startsWith("sms:") || url.startsWith("geo:") -> {
try { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } catch (_: Exception) {}
true
}
else -> false
}
}
}
inner class EasyChromeClient : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
super.onProgressChanged(view, newProgress)
if (config.showLoadingProgress && progressBar.visibility == View.VISIBLE) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
progressBar.setProgress(newProgress, true)
} else {
progressBar.progress = newProgress
}
}
}
override fun onGeolocationPermissionsShowPrompt(origin: String?, callback: GeolocationPermissions.Callback?) {
if (config.enableGeolocation) {
callback?.invoke(origin, true, false)
} else {
super.onGeolocationPermissionsShowPrompt(origin, callback)
}
}
override fun onCreateWindow(view: WebView?, isDialog: Boolean, isUserGesture: Boolean, resultMsg: Message?): Boolean {
if (config.supportMultipleWindows && resultMsg != null) {
val newWV = WebView(this@MainActivity)
newWV.settings.javaScriptEnabled = config.enableJs
newWV.webViewClient = EasyWebViewClient()
(view?.parent as? ViewGroup)?.addView(newWV)
resultMsg.obj = newWV
return true
}
return false
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK && webView.canGoBack()) {
webView.goBack()
return true
}
return super.onKeyDown(keyCode, event)
}
override fun onBackPressed() {
if (webView.canGoBack()) webView.goBack() else super.onBackPressed()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
webView.saveState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
webView.restoreState(savedInstanceState)
}
override fun onResume() {
super.onResume()
webView.onResume()
webView.resumeTimers()
}
override fun onPause() {
super.onPause()
webView.onPause()
webView.pauseTimers()
}
override fun onDestroy() {
webViewContainer.removeView(webView)
webView.removeAllViews()
webView.destroy()
super.onDestroy()
}
}

View File

@ -0,0 +1,43 @@
package com.easyappshell.memos
import android.app.Activity
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.webkit.JavascriptInterface
import android.widget.Toast
class NativeBridge(private val context: Context) {
@JavascriptInterface
fun showToast(message: String) {
(context as? Activity)?.runOnUiThread {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
@JavascriptInterface
fun shareUrl(title: String, url: String) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TITLE, title)
putExtra(Intent.EXTRA_TEXT, url)
}
(context as? Activity)?.runOnUiThread {
context.startActivity(Intent.createChooser(intent, "分享"))
}
}
@JavascriptInterface
fun getAppVersion(): String = "1.0.0"
@JavascriptInterface
fun copyToClipboard(text: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE)
as android.content.ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("label", text))
(context as? Activity)?.runOnUiThread {
Toast.makeText(context, "已复制到剪贴板", Toast.LENGTH_SHORT).show()
}
}
}

View File

@ -0,0 +1,87 @@
package com.easyappshell.memos
import android.content.Context
import org.json.JSONException
import org.json.JSONObject
import java.io.IOException
import java.nio.charset.Charset
data class AppConfig(
val appName: String,
val mainUrl: String,
val loadErrorPage: String,
val enableJs: Boolean,
val enableDomStorage: Boolean,
val enableFileAccess: Boolean,
val allowJavascriptInterface: Boolean,
val supportMultipleWindows: Boolean,
val userAgentSuffix: String,
val statusBarColor: String,
val statusBarLightIcons: Boolean,
val orientation: String,
val showLoadingProgress: Boolean,
val enableSwipeRefresh: Boolean,
val clearCacheOnStart: Boolean,
val allowHttp: Boolean,
val allowContentUrl: Boolean,
val enableGeolocation: Boolean,
val geolocationApiHost: String,
val extraHttpHeaders: Map<String, String>,
val allowedOriginWhitelist: List<String>,
val blockedHosts: List<String>,
val pullToRefreshColors: List<String>
) {
companion object {
private const val CONFIG_FILE = "config.json"
fun load(context: Context): AppConfig {
val jsonString = try {
context.assets.open(CONFIG_FILE)
.bufferedReader(Charset.forName("UTF-8"))
.use { it.readText() }
} catch (e: IOException) {
throw IllegalStateException("无法读取 $CONFIG_FILE: ${e.message}", e)
}
return try {
val json = JSONObject(jsonString)
parse(json)
} catch (e: JSONException) {
throw IllegalStateException("解析 $CONFIG_FILE 失败: ${e.message}", e)
}
}
private fun parse(json: JSONObject): AppConfig = AppConfig(
appName = json.optString("app_name", "EasyAppShell"),
mainUrl = json.optString("main_url", "https://example.com"),
loadErrorPage = json.optString("load_error_page", "file:///android_asset/error.html"),
enableJs = json.optBoolean("enable_js", true),
enableDomStorage = json.optBoolean("enable_dom_storage", true),
enableFileAccess = json.optBoolean("enable_file_access", false),
allowJavascriptInterface = json.optBoolean("allow_javascript_interface", true),
supportMultipleWindows = json.optBoolean("support_multiple_windows", false),
userAgentSuffix = json.optString("user_agent_suffix", "EasyAppShell"),
statusBarColor = json.optString("status_bar_color", "#2196F3"),
statusBarLightIcons = json.optBoolean("status_bar_light_icons", true),
orientation = json.optString("orientation", "unspecified"),
showLoadingProgress = json.optBoolean("show_loading_progress", true),
enableSwipeRefresh = json.optBoolean("enable_swipe_refresh", true),
clearCacheOnStart = json.optBoolean("clear_cache_on_start", false),
allowHttp = json.optBoolean("allow_http", false),
allowContentUrl = json.optBoolean("allow_content_url", false),
enableGeolocation = json.optBoolean("enable_geolocation", true),
geolocationApiHost = json.optString("geolocation_api_host", ""),
extraHttpHeaders = json.optJSONObject("extra_http_headers")
?.let { obj -> obj.keys().asSequence().associate { it to obj.getString(it) } }
?: emptyMap(),
allowedOriginWhitelist = json.optJSONArray("allowed_origin_whitelist")
?.let { arr -> (0 until arr.length()).map { arr.getString(it) } }
?: listOf("*"),
blockedHosts = json.optJSONArray("blocked_hosts")
?.let { arr -> (0 until arr.length()).map { arr.getString(it) } }
?: emptyList(),
pullToRefreshColors = json.optJSONArray("pull_to_refresh_colors")
?.let { arr -> (0 until arr.length()).map { arr.getString(it) } }
?: listOf("#2196F3")
)
}
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#2196F3"
android:pathData="M0,0h108v108h-108z" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- 加载进度条 -->
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="3dp"
android:max="100"
android:progress="0"
android:visibility="gone"
android:indeterminate="false" />
<!-- WebView 容器 -->
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/webViewContainer"
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/webView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<!-- 错误页(加载失败时显示) -->
<TextView
android:id="@+id/errorView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:padding="32dp"
android:textSize="14sp"
android:textColor="#666666"
android:visibility="gone"
android:drawableTop="@android:drawable/ic_dialog_alert"
android:drawablePadding="16dp"
android:lineSpacingExtra="4dp" />
</LinearLayout>

View File

@ -0,0 +1,5 @@
<?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>

View File

@ -0,0 +1,5 @@
<?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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 夜间模式主题 -->
<style name="Theme.EasyAppShell" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/primary_dark</item>
<item name="colorAccent">@color/accent</item>
<item name="android:windowBackground">@color/background_dark</item>
<item name="android:windowTranslucentStatus">false</item>
<item name="android:windowTranslucentNavigation">false</item>
</style>
</resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 主题色 - 仅供参考,实际颜色由 config.json 控制 -->
<color name="primary">#2196F3</color>
<color name="primary_dark">#1976D2</color>
<color name="accent">#03A9F4</color>
<!-- 背景 -->
<color name="background_light">#FFFFFF</color>
<color name="background_dark">#121212</color>
<!-- 文字 -->
<color name="text_primary">#212121</color>
<color name="text_secondary">#757575</color>
<!-- 进度条 -->
<color name="progress_bar">#2196F3</color>
</resources>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- App 名称(会被 config.json 中的 app_name 覆盖) -->
<string name="app_name">Memos</string>
<!-- 通用 -->
<string name="loading">加载中…</string>
<!-- 错误页 -->
<string name="error_loading_page">加载失败 (错误码: %1$d)\n%2$s</string>
<!-- 分享 -->
<string name="share_title">分享</string>
<!-- 剪贴板 -->
<string name="copied">已复制到剪贴板</string>
</resources>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 基础主题 -->
<style name="Theme.EasyAppShell" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/primary_dark</item>
<item name="colorAccent">@color/accent</item>
<item name="android:windowBackground">@color/background_light</item>
<item name="android:windowTranslucentStatus">false</item>
<item name="android:windowTranslucentNavigation">false</item>
</style>
</resources>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
网络安全配置
允许明文 HTTP 访问(如果 config.json 中 allow_http = true 则需要放开)
同时也允许加载本地 asset 中的内容
-->
<network-security-config>
<!-- 默认情况下只允许 HTTPS -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<!-- 本地 Asset 允许加载 -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
<!-- 如果 App 需要加载 HTTP 站点,请在 build 脚本中动态替换此文件 -->
</network-security-config>

489
auto_package.py Normal file
View File

@ -0,0 +1,489 @@
#!/usr/bin/env python3
"""
EasyAppShell 自动打包工具
=========================
一键修改 URLApp 名称图标然后重新打包生成 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()

6
build.gradle.kts Normal file
View File

@ -0,0 +1,6 @@
// Top-level build file for EasyAppShell
// 配置化 Android WebView 壳项目
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
}

7
gradle.properties Normal file
View File

@ -0,0 +1,7 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
android.suppressUnsupportedCompileSdk=36

17
gradle/libs.versions.toml Normal file
View File

@ -0,0 +1,17 @@
[versions]
agp = "8.7.3"
kotlin = "2.1.0"
core-ktx = "1.15.0"
activity-ktx = "1.9.3"
webkit = "1.12.1"
appcompat = "1.7.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity-ktx" }
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

119
gradlew vendored Normal file
View File

@ -0,0 +1,119 @@
#!/bin/sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld -- "$app_path" )
link=${ls#*' -> '}
case $link in
/*) app_path=$link ;;
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in
CYGWIN* ) cygwin=true ;;
Darwin* ) darwin=true ;;
MSYS* | MINGW* ) msys=true ;;
NonStop* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
;;
esac
case $MAX_FD in
'' | soft) :;;
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
;;
esac
fi
# Collect all arguments for the java command, stracks://stackoverflow.com/a/45420629
# shellcheck disable=SC2086,SC2154
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "-Dorg.gradle.appname=$APP_BASE_NAME" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
exec "$JAVACMD" "$@"

80
gradlew.bat vendored Normal file
View File

@ -0,0 +1,80 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %OS%=="Windows_NT" endlocal
:omega

24
settings.gradle.kts Normal file
View File

@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
mavenContent {
includeGroupAndSubgroups("androidx")
includeGroupAndSubgroups("com.android")
includeGroupAndSubgroups("com.google")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
@Suppress("UnstableApiUsage")
repositories {
google()
mavenCentral()
}
}
rootProject.name = "EasyAppShell"
include(":app")