diff --git a/packages/client-android/android/app/src/main/java/dev/yuzu/gca/MainActivity.kt b/packages/client-android/android/app/src/main/java/dev/yuzu/gca/MainActivity.kt
index 1cf5fb2..6f9fecd 100644
--- a/packages/client-android/android/app/src/main/java/dev/yuzu/gca/MainActivity.kt
+++ b/packages/client-android/android/app/src/main/java/dev/yuzu/gca/MainActivity.kt
@@ -1,61 +1,255 @@
package dev.yuzu.gca
-import android.os.Build
+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.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
-import com.facebook.react.ReactActivity
-import com.facebook.react.ReactActivityDelegate
-import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
-import com.facebook.react.defaults.DefaultReactActivityDelegate
+class MainActivity : AppCompatActivity() {
+ private lateinit var logView: TextView
+ private var selectedChannel = "stable"
-import expo.modules.ReactActivityDelegateWrapper
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
-class MainActivity : ReactActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- // Set the theme to AppTheme BEFORE onCreate to support
- // coloring the background, status bar, and navigation bar.
- // This is required for expo-splash-screen.
- setTheme(R.style.AppTheme);
- super.onCreate(null)
- }
+ val scroll = ScrollView(this)
+ val layout = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(24, 24, 24, 24)
+ }
- /**
- * Returns the name of the main component registered from JavaScript. This is used to schedule
- * rendering of the component.
- */
- override fun getMainComponentName(): String = "main"
+ val title = TextView(this).apply {
+ text = "Yuzu GCA"
+ textSize = 20f
+ setPadding(0, 0, 0, 4)
+ }
+ layout.addView(title)
- /**
- * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
- * which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
- */
- override fun createReactActivityDelegate(): ReactActivityDelegate {
- return ReactActivityDelegateWrapper(
- this,
- BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
- object : DefaultReactActivityDelegate(
- this,
- mainComponentName,
- fabricEnabled
- ){})
- }
+ val versionInfo = TextView(this).apply {
+ text = "v0.1.0-dav-android-8-debug"
+ textSize = 12f
+ setTextColor(Color.GRAY)
+ setPadding(0, 0, 0, 12)
+ }
+ layout.addView(versionInfo)
- /**
- * Align the back button behavior with Android S
- * where moving root activities to background instead of finishing activities.
- * @see onBackPressed
- */
- override fun invokeDefaultOnBackPressed() {
- if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
- if (!moveTaskToBack(false)) {
- // For non-root activities, use the default implementation to finish them.
- super.invokeDefaultOnBackPressed()
- }
- return
- }
+ val channelLabel = TextView(this).apply {
+ text = "选择更新渠道:"
+ textSize = 13f
+ setPadding(0, 8, 0, 4)
+ }
+ layout.addView(channelLabel)
- // Use the default back button implementation on Android S
- // because it's doing more than [Activity.moveTaskToBack] in fact.
- super.invokeDefaultOnBackPressed()
- }
+ // 渠道按钮行
+ val channelRow = LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ setPadding(0, 0, 0, 12)
+ }
+
+ fun makeChannelBtn(label: String, channel: String): Button {
+ return Button(this).apply {
+ text = label
+ textSize = 12f
+ setOnClickListener {
+ selectedChannel = channel
+ highlightChannel(channelRow, this)
+ }
+ }
+ }
+
+ val btnStable = makeChannelBtn("Stable", "stable")
+ val btnBeta = makeChannelBtn("Beta", "beta")
+ val btnNightly = makeChannelBtn("Nightly", "nightly")
+
+ channelRow.addView(btnStable)
+ channelRow.addView(btnBeta)
+ channelRow.addView(btnNightly)
+ layout.addView(channelRow)
+ highlightChannel(channelRow, btnStable)
+
+ logView = TextView(this).apply {
+ textSize = 12f
+ setTextColor(Color.LTGRAY)
+ }
+ layout.addView(logView)
+
+ val btn = Button(this).apply {
+ text = "检查更新"
+ setOnClickListener {
+ val ch = selectedChannel
+ CoroutineScope(Dispatchers.IO).launch { doOtaTest(ch) }
+ }
+ }
+ layout.addView(btn)
+
+ scroll.addView(layout)
+ setContentView(scroll)
+
+ appendLog("渠道: stable | 点击[检查更新]开始")
+ }
+
+ private fun highlightChannel(row: LinearLayout, active: Button) {
+ for (i in 0 until row.childCount) {
+ val b = row.getChildAt(i) as Button
+ b.isSelected = (b == active)
+ }
+ }
+
+ private fun appendLog(msg: String) {
+ Log.i(TAG, msg)
+ runOnUiThread { logView.append("$msg\n") }
+ }
+
+ 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) }
+ }
}
diff --git a/packages/client-android/android/app/src/main/res/xml/file_paths.xml b/packages/client-android/android/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..2f50ff3
--- /dev/null
+++ b/packages/client-android/android/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/packages/client-android/android/gradle.properties b/packages/client-android/android/gradle.properties
index 7531e9e..c1222ef 100644
--- a/packages/client-android/android/gradle.properties
+++ b/packages/client-android/android/gradle.properties
@@ -54,3 +54,7 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false
+systemProp.http.proxyHost=127.0.0.1
+systemProp.http.proxyPort=10808
+systemProp.https.proxyHost=127.0.0.1
+systemProp.https.proxyPort=10808