🎉 初始化 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
@@ -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")
|
||||
}
|
||||
@@ -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.**
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 319 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 319 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||