commit 2e93f8c282e0d13e2900886b40399638a1a01110 Author: Tatta Date: Thu Jul 2 22:52:37 2026 +0800 🎉 初始化 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f0009d --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2220e3 --- /dev/null +++ b/README.md @@ -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-5MB,Release 版约 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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..0715316 --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..5a77c93 --- /dev/null +++ b/app/proguard-rules.pro @@ -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.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..486adae --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/config.json b/app/src/main/assets/config.json new file mode 100644 index 0000000..aacf225 --- /dev/null +++ b/app/src/main/assets/config.json @@ -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" + ] +} diff --git a/app/src/main/assets/error.html b/app/src/main/assets/error.html new file mode 100644 index 0000000..d53a972 --- /dev/null +++ b/app/src/main/assets/error.html @@ -0,0 +1,71 @@ + + + + + + 加载失败 + + + +
+
📡
+

网络开小差了

+

页面加载失败,请检查网络连接后重试。

+ +
+ + + diff --git a/app/src/main/java/com/easyappshell/memos/MainActivity.kt b/app/src/main/java/com/easyappshell/memos/MainActivity.kt new file mode 100644 index 0000000..c1434dd --- /dev/null +++ b/app/src/main/java/com/easyappshell/memos/MainActivity.kt @@ -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 { + 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 { + 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() + } +} diff --git a/app/src/main/java/com/easyappshell/memos/NativeBridge.kt b/app/src/main/java/com/easyappshell/memos/NativeBridge.kt new file mode 100644 index 0000000..557c3e5 --- /dev/null +++ b/app/src/main/java/com/easyappshell/memos/NativeBridge.kt @@ -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() + } + } +} diff --git a/app/src/main/java/com/easyappshell/memos/WebAppConfig.kt b/app/src/main/java/com/easyappshell/memos/WebAppConfig.kt new file mode 100644 index 0000000..1f4ffdd --- /dev/null +++ b/app/src/main/java/com/easyappshell/memos/WebAppConfig.kt @@ -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, + val allowedOriginWhitelist: List, + val blockedHosts: List, + val pullToRefreshColors: List +) { + 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") + ) + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..3241f5b --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.png b/app/src/main/res/drawable/ic_launcher_foreground.png new file mode 100644 index 0000000..0913561 Binary files /dev/null and b/app/src/main/res/drawable/ic_launcher_foreground.png differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..6713386 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..0913561 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..0913561 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..0913561 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..0913561 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..0913561 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..0913561 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..0913561 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..0913561 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..0913561 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..0913561 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..56abfb8 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..d44d518 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,18 @@ + + + + #2196F3 + #1976D2 + #03A9F4 + + + #FFFFFF + #121212 + + + #212121 + #757575 + + + #2196F3 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..ac1f005 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,17 @@ + + + + Memos + + + 加载中… + + + 加载失败 (错误码: %1$d)\n%2$s + + + 分享 + + + 已复制到剪贴板 + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..a6fbec8 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..e12f8f1 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + localhost + 127.0.0.1 + + + + diff --git a/auto_package.py b/auto_package.py new file mode 100644 index 0000000..1af9826 --- /dev/null +++ b/auto_package.py @@ -0,0 +1,489 @@ +#!/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() diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..0b0e0b8 --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..5506306 --- /dev/null +++ b/gradle.properties @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..aeadd12 --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..9355b41 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..bcfb3fb --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ba81cca --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..95a871c --- /dev/null +++ b/settings.gradle.kts @@ -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")