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