1 Commits

Author SHA1 Message Date
LukeMackin
3e7c98a099 fix(android): 审查修复 — Kotlin编译错误+SHA256校验+权限检查
- 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
2026-07-19 01:48:00 +08:00
6 changed files with 45 additions and 31 deletions

View File

@@ -30,7 +30,7 @@
"updates": { "updates": {
"enabled": true, "enabled": true,
"checkAutomatically": "ON_LOAD", "checkAutomatically": "ON_LOAD",
"fallbackToCacheTimeout": 0, "fallbackToCacheTimeout": 3000,
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/manifest/expo-manifest.json" "url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/manifest/expo-manifest.json"
}, },
"extra": { "extra": {

View File

@@ -2,6 +2,7 @@
"version": "0.1.0", "version": "0.1.0",
"builds": { "builds": {
"android": { "android": {
"type": "apk",
"tag": "v0.1.0-dav-android-1", "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", "url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-1/gca-0.1.0.apk",
"sha256": "PLACEHOLDER", "sha256": "PLACEHOLDER",

View File

@@ -4,6 +4,7 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.modules.ModuleDefinition
import java.io.File import java.io.File
@@ -23,6 +24,11 @@ class PackageInstallerModule : Module() {
val apkFile = File(filePath) val apkFile = File(filePath)
if (!apkFile.exists()) throw Exception("APK file not found: $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 packageInstaller = context.packageManager.packageInstaller
val sessionParams = PackageInstaller.SessionParams( val sessionParams = PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL PackageInstaller.SessionParams.MODE_FULL_INSTALL
@@ -35,15 +41,15 @@ class PackageInstallerModule : Module() {
FileInputStream(apkFile).use { input -> FileInputStream(apkFile).use { input ->
session.openWrite("package", 0, apkFile.length()).use { output -> session.openWrite("package", 0, apkFile.length()).use { output ->
input.copyTo(output) input.copyTo(output)
session.fsync(output)
} }
session.fsync(output)
} }
// 安装完成后发送通知,用户点击即重启 // 安装完成后发送通知,用户点击即重启
val intent = Intent(context, context.javaClass) val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
intent.action = Intent.ACTION_MAIN ?: throw Exception("无法获取启动Intent")
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(
context, 0, intent, context, 0, launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )

View File

@@ -14,7 +14,7 @@
"@yuzu-gca/shared": "workspace:*", "@yuzu-gca/shared": "workspace:*",
"expo": "~52.0.0", "expo": "~52.0.0",
"expo-constants": "~17.0.0", "expo-constants": "~17.0.0",
"expo-device": "~7.0.0", "expo-crypto": "~14.0.0",
"expo-file-system": "~18.0.0", "expo-file-system": "~18.0.0",
"expo-status-bar": "~2.0.0", "expo-status-bar": "~2.0.0",
"expo-updates": "~26.0.0", "expo-updates": "~26.0.0",

View File

@@ -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 Constants from 'expo-constants';
import * as Device from 'expo-device'; import * as Crypto from 'expo-crypto';
import * as FileSystem from 'expo-file-system'; import * as FileSystem from 'expo-file-system';
import { NativeModules, Platform } from 'react-native'; import { NativeModules, Platform } from 'react-native';
@@ -8,37 +8,36 @@ import { NativeModules, Platform } from 'react-native';
// 1. 版本号读取 // 1. 版本号读取
// ============================================================ // ============================================================
/** 从 app.json + expo-updates 获取本地版本 */
export function getLocalVersion(): string { export function getLocalVersion(): string {
return Constants.expoConfig?.version ?? '0.1.0'; return Constants.expoConfig?.version ?? '0.1.0';
} }
export function getLocalBuildTag(): string { export function getLocalBuildTag(): string {
const v = getLocalVersion(); 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 OTAexpo-updates 自动处理) // 2. JS Bundle OTAexpo-updates 自动处理)
// ============================================================ // ============================================================
/** 手动触发 OTA 检查expo-updates 已自动启动检查,此方法用于设置页手动刷新) */
export async function checkJsBundleUpdate(): Promise<{ export async function checkJsBundleUpdate(): Promise<{
hasUpdate: boolean; hasUpdate: boolean;
remoteVersion: string; remoteVersion: string;
}> { }> {
const localVersion = getLocalVersion(); const localVersion = getLocalVersion();
const localTag = getLocalBuildTag(); 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) { if (!result.shouldUpdate || !result.remoteVersion) {
return { hasUpdate: false, remoteVersion: localVersion }; return { hasUpdate: false, remoteVersion: localVersion };
} }
// expo-updates 会在检测到新 manifest 时自动下载 bundle
// 这里只返回状态;实际下载由 expo-updates 后台完成
return { return {
hasUpdate: semverGt(result.remoteVersion.raw.replace(/^v/, '').split('-')[0], localVersion), hasUpdate: true,
remoteVersion: result.remoteVersion.raw, remoteVersion: result.remoteVersion.raw,
}; };
} }
@@ -49,8 +48,16 @@ export async function checkJsBundleUpdate(): Promise<{
const { PackageInstallerModule } = NativeModules; const { PackageInstallerModule } = NativeModules;
/** 后台下载 APK 并通过 PackageInstaller 静默安装 */ /** 计算文件 SHA256 */
export async function downloadAndInstallApk(apkUrl: string, sha256: string): Promise<void> { async function sha256File(fileUri: string): Promise<string> {
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<void> {
const localPath = `${FileSystem.cacheDirectory ?? ''}gca-update.apk`; const localPath = `${FileSystem.cacheDirectory ?? ''}gca-update.apk`;
const download = FileSystem.createDownloadResumable( const download = FileSystem.createDownloadResumable(
@@ -66,9 +73,14 @@ export async function downloadAndInstallApk(apkUrl: string, sha256: string): Pro
const result = await download.downloadAsync(); const result = await download.downloadAsync();
if (!result?.uri) throw new Error('APK 下载失败'); 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) { if (Platform.OS === 'android' && PackageInstallerModule) {
await PackageInstallerModule.installApk(result.uri); await PackageInstallerModule.installApk(result.uri);
console.log('[OTA] APK 已提交安装,用户下次重启生效'); 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<{ export async function onAppStartCheckUpdate(): Promise<{
@@ -94,18 +106,11 @@ export async function onAppStartCheckUpdate(): Promise<{
return { jsUpdate: false, apkUpdate: false }; return { jsUpdate: false, apkUpdate: false };
} }
// JS OTA: expo-updates 自动处理,无需手动干预 if (result.build.type === 'apk') {
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 安装包
await downloadAndInstallApk(result.build.url, result.build.sha256); await downloadAndInstallApk(result.build.url, result.build.sha256);
return { jsUpdate: false, apkUpdate: true }; return { jsUpdate: false, apkUpdate: true };
} }
return { jsUpdate, apkUpdate: false }; // type === 'js': expo-updates 自动处理
return { jsUpdate: true, apkUpdate: false };
} }

View File

@@ -18,6 +18,7 @@ export interface ManifestBuild {
url: string; url: string;
sha256: string; sha256: string;
size: number; size: number;
type: 'js' | 'apk';
minVersion?: string; minVersion?: string;
} }
@@ -67,7 +68,8 @@ export async function checkUpdate(
build, build,
remoteVersion, remoteVersion,
}; };
} catch { } catch (e) {
console.warn('[GCA] checkUpdate 失败:', e);
return { shouldUpdate: false, build: null, remoteVersion: null }; return { shouldUpdate: false, build: null, remoteVersion: null };
} }
} }