From bfc70189e370756d7709920b2faff8f941447156 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Mon, 17 Aug 2026 21:57:54 +0800 Subject: [PATCH] feat(mobile): install Android updates in app --- apps/mobile/app.json | 3 + apps/mobile/package.json | 1 + .../src/components/MobileUpdateCard.tsx | 22 ++--- .../src/components/MobileUpdateToast.tsx | 48 +++++++++-- apps/mobile/src/lib/android-apk-update.ts | 56 ++++++++++++ apps/mobile/src/lib/mobile-release.test.ts | 28 +++++- apps/mobile/src/lib/mobile-release.ts | 51 +++++++++-- apps/mobile/src/lib/mobile-update.tsx | 85 ++++++++++++------- bun.lock | 3 + 9 files changed, 238 insertions(+), 59 deletions(-) create mode 100644 apps/mobile/src/lib/android-apk-update.ts diff --git a/apps/mobile/app.json b/apps/mobile/app.json index b203118a..ba944a24 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -34,6 +34,9 @@ "android": { "package": "org.edgeever.mobile", "versionCode": 122, + "permissions": [ + "android.permission.REQUEST_INSTALL_PACKAGES" + ], "adaptiveIcon": { "backgroundColor": "#16A06E", "backgroundImage": "./assets/icon.png", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 8b75d8b0..2df8a2e6 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -36,6 +36,7 @@ "expo-file-system": "~57.0.1", "expo-image-manipulator": "~57.0.5", "expo-image-picker": "~57.0.5", + "expo-intent-launcher": "~57.0.1", "expo-router": "latest", "expo-secure-store": "latest", "expo-sharing": "~57.0.8", diff --git a/apps/mobile/src/components/MobileUpdateCard.tsx b/apps/mobile/src/components/MobileUpdateCard.tsx index 87c5be41..6d9203bf 100644 --- a/apps/mobile/src/components/MobileUpdateCard.tsx +++ b/apps/mobile/src/components/MobileUpdateCard.tsx @@ -10,20 +10,22 @@ import { useMobileTheme, resolveMobileThemeStyles } from "../lib/mobile-theme"; export const MobileUpdateCard = () => { const { resolvedLocale } = useMobileLocale(); const { resolvedTheme } = useMobileTheme(); - const { checkForUpdate, hasUpdate, isSupported, openUpdate, status, updateKind } = useMobileUpdate(); + const { checkForUpdate, downloadProgress, hasUpdate, isSupported, openUpdate, status, updateKind } = useMobileUpdate(); const english = resolvedLocale === "en-US"; - const busy = status === "checking" || status === "downloading"; + const busy = status === "checking" || status === "downloading" || status === "installing"; const styles = resolveMobileThemeStyles(baseStyles, resolvedTheme); const checkLabel = status === "checking" ? (english ? "Checking…" : "正在检查…") : (english ? "Check for updates" : "检查更新"); const openLabel = status === "downloading" - ? (english ? "Downloading…" : "正在下载…") + ? (english ? `Downloading ${Math.round((downloadProgress ?? 0) * 100)}%` : `正在下载 ${Math.round((downloadProgress ?? 0) * 100)}%`) + : status === "installing" + ? (english ? "Opening installer…" : "正在打开安装器…") : status === "ready" ? (english ? "Restart to apply" : "重启以应用") : updateKind === "ota" ? (english ? "Download update" : "下载更新") - : (english ? "Get update" : "前往更新"); + : (english ? "Download & install" : "下载并安装"); return ( @@ -31,8 +33,8 @@ export const MobileUpdateCard = () => { {english ? "App updates" : "应用更新"} {english - ? "EdgeEver automatically checks for compatible in-app updates. Manual checks also look for newer installable versions." - : "EdgeEver 会自动检查兼容的应用内热更新;手动检查还会查找更新的安装包版本。"} + ? "EdgeEver automatically checks for compatible in-app updates and newer installable versions." + : "EdgeEver 会自动检查兼容的应用内热更新和新版安装包。"} {english ? "Current version" : "当前版本"}: v{Updates.runtimeVersion ?? Constants.expoConfig?.version ?? "unknown"} @@ -41,12 +43,12 @@ export const MobileUpdateCard = () => { {hasUpdate ? ( void openUpdate()} - style={[styles.button, status === "downloading" && styles.buttonDisabled]} + style={[styles.button, busy && styles.buttonDisabled]} > - {status === "downloading" ? : } + {status === "downloading" || status === "installing" ? : } {openLabel} ) : null} diff --git a/apps/mobile/src/components/MobileUpdateToast.tsx b/apps/mobile/src/components/MobileUpdateToast.tsx index 8dd0cbcc..2e8d22b6 100644 --- a/apps/mobile/src/components/MobileUpdateToast.tsx +++ b/apps/mobile/src/components/MobileUpdateToast.tsx @@ -24,12 +24,13 @@ export const MobileUpdateToast = () => { const insets = useSafeAreaInsets(); const { resolvedLocale } = useMobileLocale(); const { resolvedTheme } = useMobileTheme(); - const { hasUpdate, installedVersion, openUpdate, status, updateKind } = useMobileUpdate(); + const { downloadProgress, hasUpdate, installedVersion, openUpdate, status, updateKind } = useMobileUpdate(); const english = resolvedLocale === "en-US"; const styles = resolveMobileThemeStyles(baseStyles, resolvedTheme); const [storageReady, setStorageReady] = useState(false); const [visible, setVisible] = useState(false); const dismissedForVersionRef = useRef(null); + const showingDownloadRef = useRef(false); const showingRef = useRef(false); const opacity = useRef(new Animated.Value(0)).current; @@ -46,7 +47,32 @@ export const MobileUpdateToast = () => { }, []); useEffect(() => { - if (!storageReady || !hasUpdate || status === "downloading") { + if (!storageReady || !hasUpdate) { + return; + } + if (status === "downloading" || status === "installing") { + showingDownloadRef.current = true; + showingRef.current = true; + setVisible(true); + Animated.timing(opacity, { + duration: FADE_MS, + toValue: 1, + useNativeDriver: true, + }).start(); + return; + } + if (showingDownloadRef.current) { + showingDownloadRef.current = false; + Animated.timing(opacity, { + duration: FADE_MS, + toValue: 0, + useNativeDriver: true, + }).start(({ finished }) => { + if (finished) { + setVisible(false); + showingRef.current = false; + } + }); return; } if (!shouldShowMobileUpdateToastForVersion(installedVersion, dismissedForVersionRef.current)) { @@ -87,8 +113,16 @@ export const MobileUpdateToast = () => { return null; } - const title = english ? "Update available" : "发现新版本"; - const detail = status === "ready" + const title = status === "downloading" + ? (english ? "Downloading update" : "正在下载更新") + : status === "installing" + ? (english ? "Installer ready" : "安装包已就绪") + : (english ? "Update available" : "发现新版本"); + const detail = status === "downloading" + ? `${Math.round((downloadProgress ?? 0) * 100)}%` + : status === "installing" + ? (english ? "Opening the system installer" : "正在打开系统安装器") + : status === "ready" ? (english ? "Restart in System info" : "可在系统信息中重启") : updateKind === "ota" ? (english ? "Optional in-app update" : "可选的应用内更新") @@ -125,8 +159,10 @@ export const MobileUpdateToast = () => { accessibilityLabel={`${title}. ${detail}`} accessibilityRole="button" onPress={() => { - dismiss(); - void openUpdate(); + if (status !== "downloading" && status !== "installing") { + dismiss(); + void openUpdate(); + } }} style={styles.toastBody} > diff --git a/apps/mobile/src/lib/android-apk-update.ts b/apps/mobile/src/lib/android-apk-update.ts new file mode 100644 index 00000000..883ad1ce --- /dev/null +++ b/apps/mobile/src/lib/android-apk-update.ts @@ -0,0 +1,56 @@ +import type { MobileRelease } from "./mobile-release"; + +const APK_MIME_TYPE = "application/vnd.android.package-archive"; +const ACTION_VIEW = "android.intent.action.VIEW"; +const FLAG_GRANT_READ_URI_PERMISSION = 1; + +export type AndroidApkDownloadProgress = { + downloadedBytes: number; + progress: number; + totalBytes: number; +}; + +export const downloadAndInstallAndroidApk = async ( + release: MobileRelease, + onProgress: (progress: AndroidApkDownloadProgress) => void, + onDownloaded: () => void +) => { + const [{ File, Paths }, LegacyFileSystem, IntentLauncher] = await Promise.all([ + import("expo-file-system"), + import("expo-file-system/legacy"), + import("expo-intent-launcher"), + ]); + const destination = new File(Paths.cache, release.fileName); + + try { + const downloadedFile = await File.downloadFileAsync(release.downloadUrl, destination, { + idempotent: true, + onProgress: ({ bytesWritten, totalBytes }) => { + const expectedBytes = totalBytes > 0 ? totalBytes : release.size; + onProgress({ + downloadedBytes: bytesWritten, + progress: expectedBytes > 0 ? Math.min(bytesWritten / expectedBytes, 1) : 0, + totalBytes: expectedBytes, + }); + }, + }); + + if (downloadedFile.size !== release.size) { + throw new Error(`Downloaded APK size mismatch: expected ${release.size}, received ${downloadedFile.size}`); + } + + onProgress({ downloadedBytes: release.size, progress: 1, totalBytes: release.size }); + onDownloaded(); + const contentUri = await LegacyFileSystem.getContentUriAsync(downloadedFile.uri); + await IntentLauncher.startActivityAsync(ACTION_VIEW, { + data: contentUri, + flags: FLAG_GRANT_READ_URI_PERMISSION, + type: APK_MIME_TYPE, + }); + } catch (error) { + if (destination.exists) { + destination.delete(); + } + throw error; + } +}; diff --git a/apps/mobile/src/lib/mobile-release.test.ts b/apps/mobile/src/lib/mobile-release.test.ts index 7e16082d..93f43762 100644 --- a/apps/mobile/src/lib/mobile-release.test.ts +++ b/apps/mobile/src/lib/mobile-release.test.ts @@ -7,7 +7,12 @@ import { } from "./mobile-release"; const responseWithTag = (tagName: string, androidVersion = tagName.replace(/^v/, "")) => new Response(JSON.stringify({ - assets: [{ name: `edgeever-android-v${androidVersion}-arm64-v8a.apk` }], + assets: [{ + browser_download_url: `https://github.com/tianma-if/edgeever/releases/download/${tagName}/edgeever-android-v${androidVersion}-arm64-v8a.apk`, + digest: `sha256:${"a".repeat(64)}`, + name: `edgeever-android-v${androidVersion}-arm64-v8a.apk`, + size: 48_000_000, + }], tag_name: tagName, }), { headers: { "Content-Type": "application/json" }, @@ -17,7 +22,13 @@ const responseWithTag = (tagName: string, androidVersion = tagName.replace(/^v/, test("finds a newer formal GitHub release", async () => { const release = await findNewerMobileRelease("0.4.14", async () => responseWithTag("v0.4.15")); - expect(release).toEqual({ version: "0.4.15" }); + expect(release).toEqual({ + digest: `sha256:${"a".repeat(64)}`, + downloadUrl: "https://github.com/tianma-if/edgeever/releases/download/v0.4.15/edgeever-android-v0.4.15-arm64-v8a.apk", + fileName: "edgeever-android-v0.4.15-arm64-v8a.apk", + size: 48_000_000, + version: "0.4.15", + }); }); test("does not treat the current or an older release as an update", async () => { @@ -28,6 +39,10 @@ test("does not treat the current or an older release as an update", async () => test("uses the actual reused Android asset version instead of the overall release tag", async () => { expect(await findNewerMobileRelease("1.6.50", async () => responseWithTag("v1.7.0", "1.6.50"))).toBeNull(); expect(await findNewerMobileRelease("1.6.49", async () => responseWithTag("v1.7.0", "1.6.50"))).toEqual({ + digest: `sha256:${"a".repeat(64)}`, + downloadUrl: "https://github.com/tianma-if/edgeever/releases/download/v1.7.0/edgeever-android-v1.6.50-arm64-v8a.apk", + fileName: "edgeever-android-v1.6.50-arm64-v8a.apk", + size: 48_000_000, version: "1.6.50", }); }); @@ -43,6 +58,15 @@ test("rejects invalid release responses instead of claiming the app is current", assets: [], tag_name: "v0.4.15", })))).rejects.toThrow("exactly one Android APK"); + await expect(findNewerMobileRelease("0.4.14", async () => new Response(JSON.stringify({ + assets: [{ + browser_download_url: "https://attacker.invalid/edgeever.apk", + digest: `sha256:${"a".repeat(64)}`, + name: "edgeever-android-v0.4.15-arm64-v8a.apk", + size: 48_000_000, + }], + tag_name: "v0.4.15", + })))).rejects.toThrow("exactly one Android APK"); }); test("uses GitHub Releases as the only install update destination", () => { diff --git a/apps/mobile/src/lib/mobile-release.ts b/apps/mobile/src/lib/mobile-release.ts index 806ca150..56ff0a37 100644 --- a/apps/mobile/src/lib/mobile-release.ts +++ b/apps/mobile/src/lib/mobile-release.ts @@ -31,6 +31,10 @@ type LatestReleaseResponse = { }; export type MobileRelease = { + digest: string; + downloadUrl: string; + fileName: string; + size: number; version: string; }; @@ -41,19 +45,48 @@ const normalizeVersion = (value: string) => { const ANDROID_ASSET_PATTERN = /^edgeever-android-v(\d+\.\d+\.\d+)-arm64-v8a\.apk$/; -export const findAndroidReleaseVersion = (assets: unknown) => { +const findAndroidReleaseAsset = (assets: unknown): MobileRelease | null => { if (!Array.isArray(assets)) return null; - const versions = assets + const releases = assets .map((asset) => { - if (!asset || typeof asset !== "object" || !("name" in asset) || typeof asset.name !== "string") { + if ( + !asset || + typeof asset !== "object" || + !("name" in asset) || + typeof asset.name !== "string" + ) { return null; } - return ANDROID_ASSET_PATTERN.exec(asset.name)?.[1] ?? null; + const version = ANDROID_ASSET_PATTERN.exec(asset.name)?.[1]; + if ( + !version || + !("browser_download_url" in asset) || + typeof asset.browser_download_url !== "string" || + !asset.browser_download_url.startsWith("https://github.com/tianma-if/edgeever/releases/download/") || + !("digest" in asset) || + typeof asset.digest !== "string" || + !/^sha256:[a-f0-9]{64}$/.test(asset.digest) || + !("size" in asset) || + typeof asset.size !== "number" || + !Number.isSafeInteger(asset.size) || + asset.size <= 0 + ) { + return null; + } + return { + digest: asset.digest, + downloadUrl: asset.browser_download_url, + fileName: asset.name, + size: asset.size, + version, + }; }) - .filter((version): version is string => Boolean(version)); - return versions.length === 1 ? versions[0] : null; + .filter((release): release is MobileRelease => Boolean(release)); + return releases.length === 1 ? releases[0] : null; }; +export const findAndroidReleaseVersion = (assets: unknown) => findAndroidReleaseAsset(assets)?.version ?? null; + export const findNewerMobileRelease = async ( currentVersion: string, fetchRelease: typeof fetch = fetch @@ -81,10 +114,10 @@ export const findNewerMobileRelease = async ( if (!releaseVersion) { throw new Error(`Invalid GitHub release version: ${release.tag_name}`); } - const latestVersion = findAndroidReleaseVersion(release.assets); - if (!latestVersion) { + const latestRelease = findAndroidReleaseAsset(release.assets); + if (!latestRelease) { throw new Error("GitHub release response does not contain exactly one Android APK"); } - return gt(latestVersion, normalizedCurrentVersion) ? { version: latestVersion } : null; + return gt(latestRelease.version, normalizedCurrentVersion) ? latestRelease : null; }; diff --git a/apps/mobile/src/lib/mobile-update.tsx b/apps/mobile/src/lib/mobile-update.tsx index 5e605c44..146e80bb 100644 --- a/apps/mobile/src/lib/mobile-update.tsx +++ b/apps/mobile/src/lib/mobile-update.tsx @@ -1,22 +1,20 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import Constants from "expo-constants"; -import { AppState, Linking, Platform, type AppStateStatus } from "react-native"; +import { AppState, Platform, type AppStateStatus } from "react-native"; import * as Updates from "expo-updates"; import { Alert } from "../components/LocalizedText"; import { useMobileLocale } from "./mobile-locale"; -import { - ANDROID_INSTALL_UPDATE_SOURCES, - findNewerMobileRelease, - getDefaultMobileInstallUpdateUrl, -} from "./mobile-release"; +import { downloadAndInstallAndroidApk } from "./android-apk-update"; +import { findNewerMobileRelease, type MobileRelease } from "./mobile-release"; const FOREGROUND_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; -type MobileUpdateStatus = "idle" | "checking" | "available" | "downloading" | "ready"; +type MobileUpdateStatus = "idle" | "checking" | "available" | "downloading" | "ready" | "installing"; export type MobileUpdateKind = "install" | "ota"; type MobileUpdateContextValue = { checkForUpdate: () => Promise; + downloadProgress: number | null; hasUpdate: boolean; installedVersion: string | null; isSupported: boolean; @@ -27,6 +25,7 @@ type MobileUpdateContextValue = { const MobileUpdateContext = createContext({ checkForUpdate: async () => undefined, + downloadProgress: null, hasUpdate: false, installedVersion: null, isSupported: false, @@ -37,6 +36,8 @@ const MobileUpdateContext = createContext({ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { const { resolvedLocale } = useMobileLocale(); + const [downloadProgress, setDownloadProgress] = useState(null); + const [installRelease, setInstallRelease] = useState(null); const [status, setStatus] = useState("idle"); const [updateKind, setUpdateKind] = useState(null); const activeCheckRef = useRef | null>(null); @@ -73,6 +74,7 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { } const release = await findNewerMobileRelease(installedVersion); if (release) { + setInstallRelease(release); setUpdateKind("install"); setStatus("available"); return; @@ -85,6 +87,7 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { const result = await Updates.checkForUpdateAsync(); if (!result.isAvailable) { + setInstallRelease(null); setUpdateKind(null); setStatus("idle"); return; @@ -93,6 +96,7 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { setUpdateKind("ota"); setStatus("available"); } catch { + setInstallRelease(null); setUpdateKind(null); setStatus("idle"); } @@ -128,30 +132,46 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { const openUpdate = useCallback(async () => { if (updateKind === "install") { - if (Platform.OS === "android") { - Alert.alert( - english ? "Update available" : "发现新版本", - english - ? "Get the latest version from GitHub Releases." - : "可从 GitHub Releases 获取最新版本。", - [ - ...ANDROID_INSTALL_UPDATE_SOURCES.map((source) => ({ - text: english ? source.labelEn : source.labelZh, - onPress: () => { - void Linking.openURL(source.url); - }, - })), - { - text: english ? "Cancel" : "取消", - style: "cancel" as const, - }, - ] - ); + if (Platform.OS !== "android" || !installRelease || status === "downloading" || status === "installing") { return; } - - const url = getDefaultMobileInstallUpdateUrl(Platform.OS); - await Linking.openURL(url); + const sizeMb = Math.max(1, Math.ceil(installRelease.size / 1024 / 1024)); + Alert.alert( + english ? "Update available" : "发现新版本", + english + ? `EdgeEver ${installRelease.version} is available. Download ${sizeMb} MB and install it now?` + : `EdgeEver ${installRelease.version} 已发布。是否立即下载 ${sizeMb} MB 安装包并更新?`, + [ + { + text: english ? "Cancel" : "取消", + style: "cancel", + }, + { + text: english ? "Download & install" : "下载并安装", + onPress: () => { + setDownloadProgress(0); + setStatus("downloading"); + void downloadAndInstallAndroidApk( + installRelease, + ({ progress }) => setDownloadProgress(progress), + () => setStatus("installing") + ).then(() => { + setDownloadProgress(null); + setStatus("available"); + }).catch(() => { + setDownloadProgress(null); + setStatus("available"); + Alert.alert( + english ? "Update failed" : "更新失败", + english + ? "Could not download or open the installer. Try again later." + : "无法下载安装包或打开系统安装器,请稍后重试。" + ); + }); + }, + }, + ] + ); return; } @@ -204,21 +224,22 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { : "无法下载应用内更新,请稍后再试。" ); } - }, [english, isSupported, status, updateKind]); + }, [english, installRelease, isSupported, status, updateKind]); const value = useMemo( () => ({ checkForUpdate: () => { return runCheck(true); }, - hasUpdate: status === "available" || status === "ready" || status === "downloading", + downloadProgress, + hasUpdate: status === "available" || status === "ready" || status === "downloading" || status === "installing", installedVersion, isSupported, openUpdate, status, updateKind, }), - [installedVersion, isSupported, openUpdate, runCheck, status, updateKind] + [downloadProgress, installedVersion, isSupported, openUpdate, runCheck, status, updateKind] ); return {children}; diff --git a/bun.lock b/bun.lock index fb158df8..ba2e7ba9 100644 --- a/bun.lock +++ b/bun.lock @@ -135,6 +135,7 @@ "expo-file-system": "~57.0.1", "expo-image-manipulator": "~57.0.5", "expo-image-picker": "~57.0.5", + "expo-intent-launcher": "~57.0.1", "expo-router": "latest", "expo-secure-store": "latest", "expo-sharing": "~57.0.8", @@ -2157,6 +2158,8 @@ "expo-image-picker": ["expo-image-picker@57.0.8", "", { "dependencies": { "expo-image-loader": "~57.0.1" }, "peerDependencies": { "expo": "*" } }, "sha512-LXJCGxunnQ4beqk+TW3+llRyss/XITOKWdIjqgF1H9mL3CgKN+hvABWAqLVWapGz7RoJBf+r53FO9i48yicbbw=="], + "expo-intent-launcher": ["expo-intent-launcher@57.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-BYiPEOUeW3AkW/uxEESbS2UBw9LasJ8Heb6A8kF9Ueh6pqrXQHMNJFHzfrOP8qZfv9G2e8CTzrtzpN5p0vH5fA=="], + "expo-json-utils": ["expo-json-utils@57.0.1", "", {}, "sha512-cgTe1NqzQdYs/WN+3nIY5IZg8s0pb0xaTUbhYvxQDn137GbwRfHoGM2se3m3Vsl4Qu+B9G4RPEK5WJDEU2Do7g=="], "expo-keep-awake": ["expo-keep-awake@57.0.1", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-28lkFImeXTS+bhAjuCFV7w7tW5bXg27BJVrxv+nC/nyYa86qEa0oFeHwqol6ha5k4pdVDQgBF09GM4A1k76Ssg=="],