🎉 初始化 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
32
.gitignore
vendored
Normal 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
@ -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
|
||||
52
app/build.gradle.kts
Normal 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
@ -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.**
|
||||
49
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
31
app/src/main/assets/config.json
Normal 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"
|
||||
]
|
||||
}
|
||||
71
app/src/main/assets/error.html
Normal 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>
|
||||
335
app/src/main/java/com/easyappshell/memos/MainActivity.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
43
app/src/main/java/com/easyappshell/memos/NativeBridge.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
87
app/src/main/java/com/easyappshell/memos/WebAppConfig.kt
Normal 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")
|
||||
)
|
||||
}
|
||||
}
|
||||
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
BIN
app/src/main/res/drawable/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 319 KiB |
53
app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal 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>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 319 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 319 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 319 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 319 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 319 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 319 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 319 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 319 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 319 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 319 KiB |
12
app/src/main/res/values-night/themes.xml
Normal 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>
|
||||
18
app/src/main/res/values/colors.xml
Normal 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>
|
||||
17
app/src/main/res/values/strings.xml
Normal 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>
|
||||
12
app/src/main/res/values/themes.xml
Normal 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>
|
||||
22
app/src/main/res/xml/network_security_config.xml
Normal 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
@ -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'<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
@ -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
@ -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
@ -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" }
|
||||
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
@ -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
@ -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
@ -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")
|
||||