From 3e7c98a0993025215df5b964f4a1a37688d64d89 Mon Sep 17 00:00:00 2001 From: LukeMackin Date: Sun, 19 Jul 2026 01:48:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(android):=20=E5=AE=A1=E6=9F=A5=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20=E2=80=94=20Kotlin=E7=BC=96=E8=AF=91=E9=94=99?= =?UTF-8?q?=E8=AF=AF+SHA256=E6=A0=A1=E9=AA=8C+=E6=9D=83=E9=99=90=E6=A3=80?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PackageInstallerModule: fsync作用域修复, PendingIntent改用getLaunchIntentForPackage, 权限预检 - updater: expo-crypto SHA256校验, getLocalBuildTag优先级修正, parseVersion null安全 - ManifestBuild: 增加type字段(js/apk)替代size启发式 - app.json: fallbackToCacheTimeout 0→3000 - shared: catch静默→console.warn --- packages/client-android/app.json | 2 +- packages/client-android/manifest.json | 1 + .../dev/yuzu/gca/PackageInstallerModule.kt | 14 +++-- packages/client-android/package.json | 2 +- packages/client-android/src/updater/index.ts | 53 ++++++++++--------- packages/shared/src/updater/index.ts | 4 +- 6 files changed, 45 insertions(+), 31 deletions(-) diff --git a/packages/client-android/app.json b/packages/client-android/app.json index 12291d4..63e2673 100644 --- a/packages/client-android/app.json +++ b/packages/client-android/app.json @@ -30,7 +30,7 @@ "updates": { "enabled": true, "checkAutomatically": "ON_LOAD", - "fallbackToCacheTimeout": 0, + "fallbackToCacheTimeout": 3000, "url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/manifest/expo-manifest.json" }, "extra": { diff --git a/packages/client-android/manifest.json b/packages/client-android/manifest.json index 3503790..3fbac2b 100644 --- a/packages/client-android/manifest.json +++ b/packages/client-android/manifest.json @@ -2,6 +2,7 @@ "version": "0.1.0", "builds": { "android": { + "type": "apk", "tag": "v0.1.0-dav-android-1", "url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-1/gca-0.1.0.apk", "sha256": "PLACEHOLDER", diff --git a/packages/client-android/modules/package-installer/android/src/main/java/dev/yuzu/gca/PackageInstallerModule.kt b/packages/client-android/modules/package-installer/android/src/main/java/dev/yuzu/gca/PackageInstallerModule.kt index b5ef34a..6b139f5 100644 --- a/packages/client-android/modules/package-installer/android/src/main/java/dev/yuzu/gca/PackageInstallerModule.kt +++ b/packages/client-android/modules/package-installer/android/src/main/java/dev/yuzu/gca/PackageInstallerModule.kt @@ -4,6 +4,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageInstaller +import android.content.pm.PackageManager import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition import java.io.File @@ -23,6 +24,11 @@ class PackageInstallerModule : Module() { val apkFile = File(filePath) if (!apkFile.exists()) throw Exception("APK file not found: $filePath") + // 检查 REQUEST_INSTALL_PACKAGES 权限 + if (!context.packageManager.canRequestPackageInstalls()) { + throw SecurityException("REQUEST_INSTALL_PACKAGES 权限未授予,请在系统设置中允许") + } + val packageInstaller = context.packageManager.packageInstaller val sessionParams = PackageInstaller.SessionParams( PackageInstaller.SessionParams.MODE_FULL_INSTALL @@ -35,15 +41,15 @@ class PackageInstallerModule : Module() { FileInputStream(apkFile).use { input -> session.openWrite("package", 0, apkFile.length()).use { output -> input.copyTo(output) + session.fsync(output) } - session.fsync(output) } // 安装完成后发送通知,用户点击即重启 - val intent = Intent(context, context.javaClass) - intent.action = Intent.ACTION_MAIN + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + ?: throw Exception("无法获取启动Intent") val pendingIntent = PendingIntent.getActivity( - context, 0, intent, + context, 0, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) diff --git a/packages/client-android/package.json b/packages/client-android/package.json index 06be123..76ef310 100644 --- a/packages/client-android/package.json +++ b/packages/client-android/package.json @@ -14,7 +14,7 @@ "@yuzu-gca/shared": "workspace:*", "expo": "~52.0.0", "expo-constants": "~17.0.0", - "expo-device": "~7.0.0", + "expo-crypto": "~14.0.0", "expo-file-system": "~18.0.0", "expo-status-bar": "~2.0.0", "expo-updates": "~26.0.0", diff --git a/packages/client-android/src/updater/index.ts b/packages/client-android/src/updater/index.ts index 721ef5d..e9800c1 100644 --- a/packages/client-android/src/updater/index.ts +++ b/packages/client-android/src/updater/index.ts @@ -1,6 +1,6 @@ -import { parseVersion, checkUpdate, semverGt } from '@yuzu-gca/shared'; +import { parseVersion, checkUpdate } from '@yuzu-gca/shared'; import Constants from 'expo-constants'; -import * as Device from 'expo-device'; +import * as Crypto from 'expo-crypto'; import * as FileSystem from 'expo-file-system'; import { NativeModules, Platform } from 'react-native'; @@ -8,37 +8,36 @@ import { NativeModules, Platform } from 'react-native'; // 1. 版本号读取 // ============================================================ -/** 从 app.json + expo-updates 获取本地版本 */ export function getLocalVersion(): string { return Constants.expoConfig?.version ?? '0.1.0'; } export function getLocalBuildTag(): string { const v = getLocalVersion(); - return `v${v}-dav-android-${Constants.expoConfig?.ios?.buildNumber ?? Constants.expoConfig?.android?.versionCode ?? 1}`; + const versionCode = Constants.expoConfig?.android?.versionCode ?? 1; + return `v${v}-dav-android-${versionCode}`; } // ============================================================ // 2. JS Bundle OTA(expo-updates 自动处理) // ============================================================ -/** 手动触发 OTA 检查(expo-updates 已自动启动检查,此方法用于设置页手动刷新) */ export async function checkJsBundleUpdate(): Promise<{ hasUpdate: boolean; remoteVersion: string; }> { const localVersion = getLocalVersion(); const localTag = getLocalBuildTag(); + const local = parseVersion(localTag); + if (!local) return { hasUpdate: false, remoteVersion: localVersion }; - const result = await checkUpdate('android', parseVersion(localTag)!); + const result = await checkUpdate('android', local); if (!result.shouldUpdate || !result.remoteVersion) { return { hasUpdate: false, remoteVersion: localVersion }; } - // expo-updates 会在检测到新 manifest 时自动下载 bundle - // 这里只返回状态;实际下载由 expo-updates 后台完成 return { - hasUpdate: semverGt(result.remoteVersion.raw.replace(/^v/, '').split('-')[0], localVersion), + hasUpdate: true, remoteVersion: result.remoteVersion.raw, }; } @@ -49,8 +48,16 @@ export async function checkJsBundleUpdate(): Promise<{ const { PackageInstallerModule } = NativeModules; -/** 后台下载 APK 并通过 PackageInstaller 静默安装 */ -export async function downloadAndInstallApk(apkUrl: string, sha256: string): Promise { +/** 计算文件 SHA256 */ +async function sha256File(fileUri: string): Promise { + const base64 = await Crypto.digestStringAsync( + Crypto.CryptoDigestAlgorithm.SHA256, + await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 }), + ); + return base64; +} + +export async function downloadAndInstallApk(apkUrl: string, expectedSha256: string): Promise { const localPath = `${FileSystem.cacheDirectory ?? ''}gca-update.apk`; const download = FileSystem.createDownloadResumable( @@ -66,9 +73,14 @@ export async function downloadAndInstallApk(apkUrl: string, sha256: string): Pro const result = await download.downloadAsync(); if (!result?.uri) throw new Error('APK 下载失败'); - console.log(`[OTA] 下载完成: ${result.uri}`); + // SHA256 完整性校验 + const actualSha256 = await sha256File(result.uri); + if (actualSha256 !== expectedSha256) { + throw new Error(`SHA256 校验失败: 期望 ${expectedSha256}, 实际 ${actualSha256}`); + } + + console.log(`[OTA] 下载完成, SHA256 校验通过: ${result.uri}`); - // 调用原生 PackageInstaller 安装 if (Platform.OS === 'android' && PackageInstallerModule) { await PackageInstallerModule.installApk(result.uri); console.log('[OTA] APK 已提交安装,用户下次重启生效'); @@ -78,7 +90,7 @@ export async function downloadAndInstallApk(apkUrl: string, sha256: string): Pro } // ============================================================ -// 4. 启动时一键检查(JS OTA + APK 更新) +// 4. 启动时一键检查 // ============================================================ export async function onAppStartCheckUpdate(): Promise<{ @@ -94,18 +106,11 @@ export async function onAppStartCheckUpdate(): Promise<{ return { jsUpdate: false, apkUpdate: false }; } - // JS OTA: expo-updates 自动处理,无需手动干预 - const jsUpdate = semverGt( - result.build.tag.replace(/^v/, '').split('-')[0], - localTag.replace(/^v/, '').split('-')[0], - ); - - // APK 大更新:需要下载 + PackageInstaller - if (result.build.size > 5_000_000) { - // >5MB = APK 安装包 + if (result.build.type === 'apk') { await downloadAndInstallApk(result.build.url, result.build.sha256); return { jsUpdate: false, apkUpdate: true }; } - return { jsUpdate, apkUpdate: false }; + // type === 'js': expo-updates 自动处理 + return { jsUpdate: true, apkUpdate: false }; } diff --git a/packages/shared/src/updater/index.ts b/packages/shared/src/updater/index.ts index 4977116..0b0d852 100644 --- a/packages/shared/src/updater/index.ts +++ b/packages/shared/src/updater/index.ts @@ -18,6 +18,7 @@ export interface ManifestBuild { url: string; sha256: string; size: number; + type: 'js' | 'apk'; minVersion?: string; } @@ -67,7 +68,8 @@ export async function checkUpdate( build, remoteVersion, }; - } catch { + } catch (e) { + console.warn('[GCA] checkUpdate 失败:', e); return { shouldUpdate: false, build: null, remoteVersion: null }; } }