feat: 热更新架构 — Android Bridge存文件, MainActivity启动时检查缓存
v5: 嵌入式页面(蓝黑主题,简单按钮) v6: 完全不同的页面(紫色渐变,卡片布局,渠道选择器) 通过Android.saveCache存HTML文件,重启App自动加载
This commit is contained in:
@@ -1,25 +1,33 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||||||
<style>
|
<style>
|
||||||
body{font-family:monospace;background:#0f172a;color:#e2e8f0;padding:16px;margin:0}
|
body{font-family:monospace;background:#0c0a1d;color:#e8e0f0;padding:16px;margin:0}
|
||||||
h1{font-size:18px;color:#38bdf8}
|
header{background:linear-gradient(135deg,#7c3aed,#2563eb);margin:-16px -16px 12px -16px;padding:20px 16px;text-align:center}
|
||||||
.chip{display:inline-block;padding:8px 16px;margin:4px;border-radius:6px;font-size:13px;cursor:pointer;border:1px solid #334155}
|
h1{font-size:20px;color:#fff;margin:0}
|
||||||
.chip-on{background:#1e40af;color:#fff;border-color:#3b82f6}
|
.sub{color:#c4b5fd;font-size:12px;margin-top:4px}
|
||||||
.chip-off{background:#1e293b;color:#94a3b8}
|
.card{background:#1a1530;border-radius:12px;padding:16px;margin:8px 0;border:1px solid #2d2650}
|
||||||
.btn{display:block;width:100%;padding:14px;margin:8px 0;border:none;border-radius:8px;font-size:16px;cursor:pointer}
|
.card-title{color:#a78bfa;font-size:14px;font-weight:bold;margin-bottom:8px}
|
||||||
.btn-check{background:#1e40af;color:#fff}
|
.chip-row{display:flex;gap:8px;margin:8px 0}
|
||||||
.btn-download{background:#15803d;color:#fff}
|
.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}
|
||||||
.log{background:#1e293b;padding:12px;border-radius:8px;margin-top:12px;max-height:300px;overflow-y:auto;font-size:12px;white-space:pre-wrap}
|
.chip-on{background:#7c3aed;color:#fff;border-color:#a78bfa}
|
||||||
.info{color:#94a3b8;font-size:12px;margin:4px 0}
|
.btn{display:block;width:100%;padding:14px;margin:8px 0;border:none;border-radius:10px;font-size:16px;cursor:pointer;font-weight:bold}
|
||||||
.badge{display:inline-block;background:#22c55e;color:#000;padding:2px 8px;border-radius:4px;font-size:11px;margin-left:8px}
|
.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>
|
</style></head><body>
|
||||||
|
<header>
|
||||||
<h1>Yuzu GCA <span class="badge">v6</span></h1>
|
<h1>Yuzu GCA <span class="badge">v6</span></h1>
|
||||||
<div class="info">version <span id="ver">0.1.0-dav-android-6</span></div>
|
<div class="sub">热更新 v6 — 渠道选择器</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div style="margin:12px 0">
|
<div class="card">
|
||||||
<span id="ch-stable" class="chip chip-on" onclick="selectCh('stable')">Stable</span>
|
<div class="card-title">更新渠道</div>
|
||||||
<span id="ch-beta" class="chip chip-off" onclick="selectCh('beta')">Beta</span>
|
<div class="chip-row">
|
||||||
<span id="ch-nightly" class="chip chip-off" onclick="selectCh('nightly')">Nightly</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-check" onclick="checkUpdate()">检查更新</button>
|
<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){
|
function selectCh(ch){
|
||||||
channel=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';
|
document.getElementById('ch-'+ch).className='chip chip-on';
|
||||||
log('channel → '+ch)
|
log('channel: '+ch)
|
||||||
}
|
}
|
||||||
|
|
||||||
function log(m){document.getElementById('log').innerText+=m+'\n'}
|
function log(m){document.getElementById('log').innerText+=m+'\n'}
|
||||||
|
|
||||||
function checkUpdate(){
|
function checkUpdate(){
|
||||||
var url=BASE+'/version.txt?t='+Date.now();
|
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'})
|
fetch(url,{cache:'no-store'})
|
||||||
.then(r=>{if(!r.ok)throw Error('HTTP '+r.status);return r.text()})
|
.then(r=>{if(!r.ok)throw Error('HTTP '+r.status);return r.text()})
|
||||||
.then(t=>{t=t.trim();log('remote: '+t);
|
.then(t=>{t=t.trim();log('remote: '+t);
|
||||||
var local=document.getElementById('ver').innerText;
|
var local='0.1.0-dav-android-6';
|
||||||
if(t!==local&&t!=='v'+local&&'v'+t!==local){dlVer=t;dlUrl=BASE+'/h5/index-v7.html';
|
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}
|
log('*** NEW VERSION ***');document.getElementById('btnApply').disabled=false}
|
||||||
else log('up to date')})
|
else log('up to date')})
|
||||||
.catch(e=>log('ERR: '+e.message))
|
.catch(e=>log('ERR: '+e.message))
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyUpdate(){
|
function applyUpdate(){
|
||||||
log('downloading...');
|
log('downloading...');
|
||||||
fetch(dlUrl+'?t='+Date.now(),{cache:'no-store'})
|
fetch(dlUrl+'?t='+Date.now(),{cache:'no-store'})
|
||||||
.then(r=>{if(!r.ok)throw Error('HTTP '+r.status);return r.text()})
|
.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);
|
.then(function(t){Android.saveCache(t);log('OK! restart app to apply')})
|
||||||
log('OK reloading...');setTimeout(function(){location.reload()},300)})
|
|
||||||
.catch(e=>log('ERR: '+e.message))
|
.catch(e=>log('ERR: '+e.message))
|
||||||
}
|
}
|
||||||
</script></body></html>
|
</script></body></html>
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><script>
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||||||
(function(){
|
|
||||||
var b=localStorage.getItem('gca_html');
|
|
||||||
if(b){
|
|
||||||
var blob=new Blob([b],{type:'text/html'});
|
|
||||||
location.replace(URL.createObjectURL(blob));
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<style>
|
<style>
|
||||||
body{font-family:monospace;background:#0f172a;color:#e2e8f0;padding:16px;margin:0}
|
body{font-family:monospace;background:#0f172a;color:#e2e8f0;padding:16px;margin:0}
|
||||||
h1{font-size:18px;color:#38bdf8}
|
h1{font-size:18px;color:#38bdf8}
|
||||||
@@ -17,7 +8,6 @@ h1{font-size:18px;color:#38bdf8}
|
|||||||
.btn-download{background:#15803d;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}
|
.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}
|
.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>
|
</style></head><body>
|
||||||
<h1>Yuzu GCA</h1>
|
<h1>Yuzu GCA</h1>
|
||||||
<div class="info">version <span id="ver">v0.1.0-dav-android-5</span></div>
|
<div class="info">version <span id="ver">v0.1.0-dav-android-5</span></div>
|
||||||
@@ -44,8 +34,8 @@ function applyUpdate(){
|
|||||||
log('downloading...');
|
log('downloading...');
|
||||||
fetch(dlUrl+'?t='+Date.now(),{cache:'no-store'})
|
fetch(dlUrl+'?t='+Date.now(),{cache:'no-store'})
|
||||||
.then(r=>{if(!r.ok)throw Error('HTTP '+r.status);return r.text()})
|
.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);
|
.then(t=>{Android.saveCache(t);log('OK! restart app to apply');
|
||||||
log('OK reloading...');setTimeout(function(){location.reload()},300)})
|
document.getElementById('btnApply').disabled=true})
|
||||||
.catch(e=>log('ERR: '+e.message))
|
.catch(e=>log('ERR: '+e.message))
|
||||||
}
|
}
|
||||||
</script></body></html>
|
</script></body></html>
|
||||||
|
|||||||
@@ -1,32 +1,15 @@
|
|||||||
package dev.yuzu.gca
|
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.os.Bundle
|
||||||
import android.provider.Settings
|
import android.util.Log
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
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 androidx.appcompat.app.AppCompatActivity
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import java.io.File
|
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() {
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -39,167 +22,32 @@ class MainActivity : AppCompatActivity() {
|
|||||||
settings.allowFileAccessFromFileURLs = true
|
settings.allowFileAccessFromFileURLs = true
|
||||||
settings.allowUniversalAccessFromFileURLs = true
|
settings.allowUniversalAccessFromFileURLs = true
|
||||||
settings.mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
settings.mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||||
webViewClient = object : WebViewClient() {
|
addJavascriptInterface(Bridge(this@MainActivity), "Android")
|
||||||
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
|
||||||
if (url.startsWith("http")) { view.loadUrl(url); return true }
|
if (cacheFile.exists()) {
|
||||||
return false
|
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)")
|
||||||
addJavascriptInterface(AndroidBridge(this@MainActivity), "Android")
|
} else {
|
||||||
loadUrl("file:///android_asset/index.html")
|
loadUrl("file:///android_asset/index.html")
|
||||||
|
Log.i(TAG, "Loaded embedded index.html")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setContentView(wv)
|
setContentView(wv)
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class AndroidBridge(private val ctx: MainActivity) {
|
inner class Bridge(private val ctx: MainActivity) {
|
||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
fun checkUpdate(channel: String) {
|
fun saveCache(html: String) {
|
||||||
CoroutineScope(Dispatchers.IO).launch { doOtaTest(channel) }
|
ctx.cacheFile.writeText(html)
|
||||||
|
Log.i(TAG, "Hot-update HTML saved (${html.length}B)")
|
||||||
}
|
}
|
||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
fun log(msg: String) { appendLog(msg) }
|
fun clearCache() {
|
||||||
}
|
ctx.cacheFile.delete()
|
||||||
|
Log.i(TAG, "Cache cleared")
|
||||||
private fun appendLog(msg: String) {
|
|
||||||
Log.i(TAG, msg)
|
|
||||||
if (::logView.isInitialized) {
|
|
||||||
runOnUiThread { logView.append("$msg\n") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object { private const val TAG = "GCA-OTA" }
|
companion object { private const val TAG = "GCA-H5" }
|
||||||
|
|
||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user