feat: 热更新架构 — Android Bridge存文件, MainActivity启动时检查缓存

v5: 嵌入式页面(蓝黑主题,简单按钮)
v6: 完全不同的页面(紫色渐变,卡片布局,渠道选择器)
通过Android.saveCache存HTML文件,重启App自动加载
This commit is contained in:
LukeMackin
2026-07-21 19:07:17 +08:00
parent bfd7bee2ae
commit a47b73dda7
3 changed files with 54 additions and 213 deletions

View File

@@ -1,25 +1,33 @@
<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<style>
body{font-family:monospace;background:#0f172a;color:#e2e8f0;padding:16px;margin:0}
h1{font-size:18px;color:#38bdf8}
.chip{display:inline-block;padding:8px 16px;margin:4px;border-radius:6px;font-size:13px;cursor:pointer;border:1px solid #334155}
.chip-on{background:#1e40af;color:#fff;border-color:#3b82f6}
.chip-off{background:#1e293b;color:#94a3b8}
.btn{display:block;width:100%;padding:14px;margin:8px 0;border:none;border-radius:8px;font-size:16px;cursor:pointer}
.btn-check{background:#1e40af;color:#fff}
.btn-download{background:#15803d;color:#fff}
.log{background:#1e293b;padding:12px;border-radius:8px;margin-top:12px;max-height:300px;overflow-y:auto;font-size:12px;white-space:pre-wrap}
.info{color:#94a3b8;font-size:12px;margin:4px 0}
.badge{display:inline-block;background:#22c55e;color:#000;padding:2px 8px;border-radius:4px;font-size:11px;margin-left:8px}
body{font-family:monospace;background:#0c0a1d;color:#e8e0f0;padding:16px;margin:0}
header{background:linear-gradient(135deg,#7c3aed,#2563eb);margin:-16px -16px 12px -16px;padding:20px 16px;text-align:center}
h1{font-size:20px;color:#fff;margin:0}
.sub{color:#c4b5fd;font-size:12px;margin-top:4px}
.card{background:#1a1530;border-radius:12px;padding:16px;margin:8px 0;border:1px solid #2d2650}
.card-title{color:#a78bfa;font-size:14px;font-weight:bold;margin-bottom:8px}
.chip-row{display:flex;gap:8px;margin:8px 0}
.chip{flex:1;text-align:center;padding:10px 0;border-radius:8px;font-size:13px;cursor:pointer;border:1px solid #4c3a80;background:#1e1740;color:#aaa}
.chip-on{background:#7c3aed;color:#fff;border-color:#a78bfa}
.btn{display:block;width:100%;padding:14px;margin:8px 0;border:none;border-radius:10px;font-size:16px;cursor:pointer;font-weight:bold}
.btn-check{background:#7c3aed;color:#fff}
.btn-download{background:#059669;color:#fff}
.log{background:#0f0b20;padding:12px;border-radius:8px;margin-top:12px;font-size:11px;color:#888;white-space:pre-wrap;max-height:200px;overflow-y:auto}
.badge{display:inline-block;background:#7c3aed;color:#fff;padding:3px 10px;border-radius:6px;font-size:11px}
</style></head><body>
<h1>Yuzu GCA <span class="badge">v6</span></h1>
<div class="info">version <span id="ver">0.1.0-dav-android-6</span></div>
<header>
<h1>Yuzu GCA <span class="badge">v6</span></h1>
<div class="sub">热更新 v6 — 渠道选择器</div>
</header>
<div style="margin:12px 0">
<span id="ch-stable" class="chip chip-on" onclick="selectCh('stable')">Stable</span>
<span id="ch-beta" class="chip chip-off" onclick="selectCh('beta')">Beta</span>
<span id="ch-nightly" class="chip chip-off" onclick="selectCh('nightly')">Nightly</span>
<div class="card">
<div class="card-title">更新渠道</div>
<div class="chip-row">
<div id="ch-stable" class="chip chip-on" onclick="selectCh('stable')">Stable</div>
<div id="ch-beta" class="chip chip-off" onclick="selectCh('beta')">Beta</div>
<div id="ch-nightly" class="chip chip-off" onclick="selectCh('nightly')">Nightly</div>
</div>
</div>
<button class="btn btn-check" onclick="checkUpdate()">检查更新</button>
@@ -31,33 +39,28 @@ var BASE='https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota
function selectCh(ch){
channel=ch;
document.querySelectorAll('.chip').forEach(c=>c.className='chip chip-off');
var chips=document.querySelectorAll('.chip');
for(var i=0;i<chips.length;i++)chips[i].className='chip chip-off';
document.getElementById('ch-'+ch).className='chip chip-on';
log('channel '+ch)
log('channel: '+ch)
}
function log(m){document.getElementById('log').innerText+=m+'\n'}
function checkUpdate(){
var url=BASE+'/version.txt?t='+Date.now();
if(channel!=='stable') url=BASE+'/version-'+channel+'.txt?t='+Date.now();
log('checking '+url+' ...');
fetch(url,{cache:'no-store'})
.then(r=>{if(!r.ok)throw Error('HTTP '+r.status);return r.text()})
.then(t=>{t=t.trim();log('remote: '+t);
var local=document.getElementById('ver').innerText;
if(t!==local&&t!=='v'+local&&'v'+t!==local){dlVer=t;dlUrl=BASE+'/h5/index-v7.html';
var local='0.1.0-dav-android-6';
if(t!==local&&t!=='0.1.0-dav-android-6'){dlVer=t;dlUrl=BASE+'/h5/index-v6.html';
log('*** NEW VERSION ***');document.getElementById('btnApply').disabled=false}
else log('up to date')})
.catch(e=>log('ERR: '+e.message))
}
function applyUpdate(){
log('downloading...');
fetch(dlUrl+'?t='+Date.now(),{cache:'no-store'})
.then(r=>{if(!r.ok)throw Error('HTTP '+r.status);return r.text()})
.then(t=>{localStorage.setItem('gca_html',t);localStorage.setItem('gca_ver',dlVer);
log('OK reloading...');setTimeout(function(){location.reload()},300)})
.then(function(t){Android.saveCache(t);log('OK! restart app to apply')})
.catch(e=>log('ERR: '+e.message))
}
</script></body></html>

View File

@@ -1,14 +1,5 @@
<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><script>
(function(){
var b=localStorage.getItem('gca_html');
if(b){
var blob=new Blob([b],{type:'text/html'});
location.replace(URL.createObjectURL(blob));
return
}
})();
</script>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<style>
body{font-family:monospace;background:#0f172a;color:#e2e8f0;padding:16px;margin:0}
h1{font-size:18px;color:#38bdf8}
@@ -17,7 +8,6 @@ h1{font-size:18px;color:#38bdf8}
.btn-download{background:#15803d;color:#fff}
.log{background:#1e293b;padding:12px;border-radius:8px;margin-top:12px;max-height:300px;overflow-y:auto;font-size:12px;white-space:pre-wrap}
.info{color:#94a3b8;font-size:12px;margin:4px 0}
.badge{display:inline-block;background:#22c55e;color:#000;padding:2px 8px;border-radius:4px;font-size:11px;margin-left:8px}
</style></head><body>
<h1>Yuzu GCA</h1>
<div class="info">version <span id="ver">v0.1.0-dav-android-5</span></div>
@@ -44,8 +34,8 @@ function applyUpdate(){
log('downloading...');
fetch(dlUrl+'?t='+Date.now(),{cache:'no-store'})
.then(r=>{if(!r.ok)throw Error('HTTP '+r.status);return r.text()})
.then(t=>{localStorage.setItem('gca_html',t);localStorage.setItem('gca_ver',dlVer);
log('OK reloading...');setTimeout(function(){location.reload()},300)})
.then(t=>{Android.saveCache(t);log('OK! restart app to apply');
document.getElementById('btnApply').disabled=true})
.catch(e=>log('ERR: '+e.message))
}
</script></body></html>

View File

@@ -1,32 +1,15 @@
package dev.yuzu.gca
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageInstaller
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import kotlinx.coroutines.*
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import android.util.Log
import androidx.core.content.FileProvider
import org.json.JSONObject
class MainActivity : AppCompatActivity() {
private lateinit var logView: TextView
private var selectedChannel = "stable"
private val cacheFile: File by lazy { File(filesDir, "gca_hot.html") }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -39,167 +22,32 @@ class MainActivity : AppCompatActivity() {
settings.allowFileAccessFromFileURLs = true
settings.allowUniversalAccessFromFileURLs = true
settings.mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
if (url.startsWith("http")) { view.loadUrl(url); return true }
return false
}
}
addJavascriptInterface(AndroidBridge(this@MainActivity), "Android")
addJavascriptInterface(Bridge(this@MainActivity), "Android")
if (cacheFile.exists()) {
val html = cacheFile.readText()
loadDataWithBaseURL("https://gca.local/", html, "text/html", "UTF-8", null)
Log.i(TAG, "Loaded cached hot-update HTML (${html.length}B)")
} else {
loadUrl("file:///android_asset/index.html")
Log.i(TAG, "Loaded embedded index.html")
}
}
setContentView(wv)
}
inner class AndroidBridge(private val ctx: MainActivity) {
inner class Bridge(private val ctx: MainActivity) {
@android.webkit.JavascriptInterface
fun checkUpdate(channel: String) {
CoroutineScope(Dispatchers.IO).launch { doOtaTest(channel) }
fun saveCache(html: String) {
ctx.cacheFile.writeText(html)
Log.i(TAG, "Hot-update HTML saved (${html.length}B)")
}
@android.webkit.JavascriptInterface
fun log(msg: String) { appendLog(msg) }
}
private fun appendLog(msg: String) {
Log.i(TAG, msg)
if (::logView.isInitialized) {
runOnUiThread { logView.append("$msg\n") }
fun clearCache() {
ctx.cacheFile.delete()
Log.i(TAG, "Cache cleared")
}
}
companion object { private const val TAG = "GCA-OTA" }
private fun manifestUrl(channel: String): String {
val base = "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/raw/branch/main/ota"
return when (channel) {
"beta" -> "$base/manifest-beta.json"
"nightly" -> "$base/manifest-nightly.json"
else -> "$base/manifest-stable.json"
}
}
private suspend fun doOtaTest(channel: String) = withContext(Dispatchers.IO) {
val url = manifestUrl(channel)
appendLog("=== OTA 渠道: $channel ===")
appendLog("manifest: $url")
// 1. 拉取 manifest
appendLog("[1/4] 拉取 manifest...")
val manifestJson: String
try {
manifestJson = httpGet(url)
appendLog(" ✅ 获取成功 (${manifestJson.length}B)")
} catch (e: Exception) {
appendLog(" ❌ 获取失败: ${e.message}")
appendLog("=== 测试中止 ===")
return@withContext
}
// 2. 解析
appendLog("[2/4] 解析 manifest...")
var manifestSha256 = ""
var manifestApkUrl = ""
var manifestTag = ""
try {
val json = JSONObject(manifestJson)
if (!json.getJSONObject("builds").has("android")) {
appendLog(" 该渠道暂无可用更新")
appendLog("=== $channel 渠道测试完成 ===")
return@withContext
}
val android = json.getJSONObject("builds").getJSONObject("android")
manifestSha256 = android.getString("sha256")
manifestApkUrl = android.getString("url")
manifestTag = android.getString("tag")
appendLog(" 渠道: $channel 版本: $manifestTag")
appendLog(" SHA256: ${manifestSha256.take(16)}...")
// 版本比较:一致则跳过
val localTag = "v0.1.0-dav-android-${packageManager.getPackageInfo(packageName, 0).versionCode}"
if (manifestTag == localTag) {
appendLog(" ✅ 已是最新版本,无需更新")
appendLog("=== $channel 渠道测试完成 ===")
return@withContext
}
appendLog(" 发现新版本,开始更新...")
} catch (e: Exception) {
appendLog(" ❌ 解析失败: ${e.message}")
return@withContext
}
// 3. 下载
appendLog("[3/4] 下载 APK...")
val apkFile = File(cacheDir, "update-$channel.apk")
try {
downloadFile(manifestApkUrl, apkFile)
appendLog(" ✅ 下载完成: ${apkFile.length()} bytes")
} catch (e: Exception) {
appendLog(" ❌ 下载失败: ${e.message}")
return@withContext
}
// 4. SHA256 校验
appendLog("[4/4] SHA256 校验...")
val actualSha256 = computeSha256(apkFile)
if (actualSha256 == manifestSha256) {
appendLog(" ✅ SHA256 校验通过!")
// 5. 安装 APK
appendLog("[5/5] 提交安装...")
try {
installApk(apkFile)
appendLog(" ✅ 安装已提交,稍后通知栏点击重启")
} catch (e: Exception) {
appendLog(" ❌ 安装失败: ${e.message}")
}
} else {
appendLog(" ❌ SHA256 校验失败!")
appendLog(" expected: ${manifestSha256.take(32)}")
appendLog(" actual: ${actualSha256.take(32)}")
}
appendLog("=== $channel 渠道测试完成 ===")
}
private fun httpGet(url: String): String {
val conn = URL(url).openConnection() as HttpURLConnection
conn.connectTimeout = 10000
conn.readTimeout = 10000
return conn.inputStream.bufferedReader().readText()
}
private val ALLOWED_HOSTS = setOf("git.childish-ghost.com", "github.com")
private fun downloadFile(url: String, dest: File) {
val host = URL(url).host
if (!ALLOWED_HOSTS.any { host == it || host.endsWith(".$it") }) {
throw SecurityException("不允许的下载域名: $host")
}
val conn = URL(url).openConnection() as HttpURLConnection
conn.connectTimeout = 30000
conn.readTimeout = 120000
conn.inputStream.use { input ->
FileOutputStream(dest).use { output -> input.copyTo(output) }
}
}
private fun installApk(apkFile: File) {
val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", apkFile)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(intent)
}
private fun computeSha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buf = ByteArray(8192)
var read: Int
while (input.read(buf).also { read = it } != -1) {
digest.update(buf, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
companion object { private const val TAG = "GCA-H5" }
}