From 85481960545f1134fbef7898aaff835deccdd0ed Mon Sep 17 00:00:00 2001 From: tianma-if Date: Fri, 28 Aug 2026 20:09:28 +0800 Subject: [PATCH] fix(android): route app updates to trusted sources --- apps/mobile/app.json | 3 - .../android/build.gradle | 15 -- .../installer/EdgeEverAppInstallerModule.kt | 110 ----------- .../expo-module.config.json | 6 - .../modules/edgeever-app-installer/index.ts | 13 -- .../edgeever-app-installer/package.json | 6 - apps/mobile/package.json | 1 - .../src/components/MobileUpdateCard.tsx | 20 +- .../mobile/src/lib/android-apk-update.test.ts | 97 ---------- apps/mobile/src/lib/android-apk-update.ts | 58 ------ apps/mobile/src/lib/mobile-release.test.ts | 23 ++- apps/mobile/src/lib/mobile-release.ts | 22 ++- apps/mobile/src/lib/mobile-update.tsx | 173 ++++++------------ apps/mobile/src/screens/WorkspaceScreen.tsx | 19 +- apps/mobile/src/screens/workspace-styles.ts | 14 ++ bun.lock | 3 - package.json | 2 +- scripts/build-android-local.sh | 11 -- .../configure-android-package-permissions.mjs | 57 ------ ...igure-android-package-permissions.test.mjs | 40 ---- scripts/plan-native-release.mjs | 1 - scripts/plan-native-release.test.mjs | 1 - scripts/release.mjs | 1 - scripts/store-delivery.test.mjs | 11 +- 24 files changed, 137 insertions(+), 570 deletions(-) delete mode 100644 apps/mobile/modules/edgeever-app-installer/android/build.gradle delete mode 100644 apps/mobile/modules/edgeever-app-installer/android/src/main/java/org/edgeever/installer/EdgeEverAppInstallerModule.kt delete mode 100644 apps/mobile/modules/edgeever-app-installer/expo-module.config.json delete mode 100644 apps/mobile/modules/edgeever-app-installer/index.ts delete mode 100644 apps/mobile/modules/edgeever-app-installer/package.json delete mode 100644 apps/mobile/src/lib/android-apk-update.test.ts delete mode 100644 apps/mobile/src/lib/android-apk-update.ts delete mode 100644 scripts/configure-android-package-permissions.mjs delete mode 100644 scripts/configure-android-package-permissions.test.mjs diff --git a/apps/mobile/app.json b/apps/mobile/app.json index f6b6d9cc..ecf0e028 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -34,9 +34,6 @@ "android": { "package": "org.edgeever.mobile", "versionCode": 142, - "permissions": [ - "android.permission.REQUEST_INSTALL_PACKAGES" - ], "adaptiveIcon": { "backgroundColor": "#16A06E", "backgroundImage": "./assets/icon.png", diff --git a/apps/mobile/modules/edgeever-app-installer/android/build.gradle b/apps/mobile/modules/edgeever-app-installer/android/build.gradle deleted file mode 100644 index da4b0db6..00000000 --- a/apps/mobile/modules/edgeever-app-installer/android/build.gradle +++ /dev/null @@ -1,15 +0,0 @@ -plugins { - id 'com.android.library' - id 'expo-module-gradle-plugin' -} - -group = 'org.edgeever.installer' -version = '1.0.0' - -android { - namespace "org.edgeever.installer" - defaultConfig { - versionCode 1 - versionName '1.0.0' - } -} diff --git a/apps/mobile/modules/edgeever-app-installer/android/src/main/java/org/edgeever/installer/EdgeEverAppInstallerModule.kt b/apps/mobile/modules/edgeever-app-installer/android/src/main/java/org/edgeever/installer/EdgeEverAppInstallerModule.kt deleted file mode 100644 index b0b749c5..00000000 --- a/apps/mobile/modules/edgeever-app-installer/android/src/main/java/org/edgeever/installer/EdgeEverAppInstallerModule.kt +++ /dev/null @@ -1,110 +0,0 @@ -package org.edgeever.installer - -import android.content.ClipData -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.provider.Settings -import androidx.core.content.FileProvider -import expo.modules.kotlin.Promise -import expo.modules.kotlin.exception.Exceptions -import expo.modules.kotlin.exception.toCodedException -import expo.modules.kotlin.modules.Module -import expo.modules.kotlin.modules.ModuleDefinition -import java.io.File - -private const val APK_MIME_TYPE = "application/vnd.android.package-archive" -private const val INSTALL_PERMISSION_REQUEST_CODE = 2401 - -class EdgeEverAppInstallerModule : Module() { - private val context: Context - get() = appContext.reactContext ?: throw Exceptions.ReactContextLost() - - private var pendingFile: File? = null - private var pendingPromise: Promise? = null - - override fun definition() = ModuleDefinition { - Name("EdgeEverAppInstaller") - - AsyncFunction("install") { fileUri: String, promise: Promise -> - if (pendingPromise != null) { - promise.reject("ERR_INSTALL_IN_PROGRESS", "Another APK install request is already in progress", null) - return@AsyncFunction - } - - try { - val file = resolveDownloadedApk(fileUri) - if (canRequestPackageInstalls()) { - openPackageInstaller(file) - promise.resolve(true) - } else { - pendingFile = file - pendingPromise = promise - val permissionIntent = Intent( - Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, - Uri.parse("package:${context.packageName}") - ) - appContext.throwingActivity.startActivityForResult(permissionIntent, INSTALL_PERMISSION_REQUEST_CODE) - } - } catch (error: Throwable) { - pendingFile = null - pendingPromise = null - promise.reject(error.toCodedException()) - } - } - - OnActivityResult { _, payload -> - if (payload.requestCode != INSTALL_PERMISSION_REQUEST_CODE) { - return@OnActivityResult - } - - val promise = pendingPromise ?: return@OnActivityResult - val file = pendingFile - pendingPromise = null - pendingFile = null - - if (file == null || !canRequestPackageInstalls()) { - promise.resolve(false) - return@OnActivityResult - } - - try { - openPackageInstaller(file) - promise.resolve(true) - } catch (error: Throwable) { - promise.reject(error.toCodedException()) - } - } - } - - private fun canRequestPackageInstalls(): Boolean { - return Build.VERSION.SDK_INT < Build.VERSION_CODES.O || context.packageManager.canRequestPackageInstalls() - } - - private fun resolveDownloadedApk(fileUri: String): File { - val uri = Uri.parse(fileUri) - require(uri.scheme == "file") { "Expected a local APK file URI" } - val path = requireNotNull(uri.path) { "APK file URI has no path" } - val file = File(path).canonicalFile - val cacheDirectory = context.cacheDir.canonicalFile - require(file.path.startsWith(cacheDirectory.path + File.separator)) { "APK must be inside the app cache" } - require(file.isFile && file.extension.equals("apk", ignoreCase = true)) { "Downloaded APK does not exist" } - return file - } - - private fun openPackageInstaller(file: File) { - val activity = appContext.throwingActivity - val contentUri = FileProvider.getUriForFile( - context, - "${context.packageName}.FileSystemFileProvider", - file - ) - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(contentUri, APK_MIME_TYPE) - clipData = ClipData.newRawUri("EdgeEver update", contentUri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - activity.startActivity(intent) - } -} diff --git a/apps/mobile/modules/edgeever-app-installer/expo-module.config.json b/apps/mobile/modules/edgeever-app-installer/expo-module.config.json deleted file mode 100644 index a828c7bc..00000000 --- a/apps/mobile/modules/edgeever-app-installer/expo-module.config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "platforms": ["android"], - "android": { - "modules": ["org.edgeever.installer.EdgeEverAppInstallerModule"] - } -} diff --git a/apps/mobile/modules/edgeever-app-installer/index.ts b/apps/mobile/modules/edgeever-app-installer/index.ts deleted file mode 100644 index 3bf9aabc..00000000 --- a/apps/mobile/modules/edgeever-app-installer/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { requireNativeModule } from "expo"; - -type EdgeEverAppInstallerNativeModule = { - install: (fileUri: string) => Promise; -}; - -/** - * Opens Android's package installer, requesting per-app install permission first when required. - * Returns false only when the user comes back without granting that permission. - */ -export const installAndroidApk = (fileUri: string) => { - return requireNativeModule("EdgeEverAppInstaller").install(fileUri); -}; diff --git a/apps/mobile/modules/edgeever-app-installer/package.json b/apps/mobile/modules/edgeever-app-installer/package.json deleted file mode 100644 index 53c21084..00000000 --- a/apps/mobile/modules/edgeever-app-installer/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "edgeever-app-installer", - "version": "1.0.0", - "main": "index.ts", - "private": true -} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ffdf76d4..30d13de7 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -36,7 +36,6 @@ "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 93677c71..c0b426a9 100644 --- a/apps/mobile/src/components/MobileUpdateCard.tsx +++ b/apps/mobile/src/components/MobileUpdateCard.tsx @@ -10,24 +10,20 @@ import { useMobileTheme, resolveMobileThemeStyles } from "../lib/mobile-theme"; export const MobileUpdateCard = () => { const { resolvedLocale } = useMobileLocale(); const { resolvedTheme } = useMobileTheme(); - const { checkForUpdate, downloadProgress, hasUpdate, isSupported, openUpdate, status, updateKind } = useMobileUpdate(); + const { checkForUpdate, hasUpdate, isSupported, openUpdate, status, updateKind } = useMobileUpdate(); const english = resolvedLocale === "en-US"; - const busy = status === "checking" || status === "downloading" || status === "installing"; + const busy = status === "checking" || status === "downloading"; const styles = resolveMobileThemeStyles(baseStyles, resolvedTheme); const checkLabel = status === "checking" ? (english ? "Checking…" : "正在检查…") : (english ? "Check for updates" : "检查更新"); const openLabel = status === "downloading" - ? (english ? `Downloading ${Math.round((downloadProgress ?? 0) * 100)}%` : `正在下载 ${Math.round((downloadProgress ?? 0) * 100)}%`) - : status === "installing" - ? (english ? "Opening installer…" : "正在打开安装器…") + ? (english ? "Downloading update…" : "正在下载更新…") : status === "ready" - ? updateKind === "install" - ? (english ? "Install now" : "立即安装") - : (english ? "Restart to apply" : "重启以应用") + ? (english ? "Restart to apply" : "重启以应用") : updateKind === "ota" ? (english ? "Download update" : "下载更新") - : (english ? "Prepare update" : "准备更新"); + : (english ? "Choose update source" : "选择更新渠道"); return ( @@ -35,8 +31,8 @@ export const MobileUpdateCard = () => { {english ? "App updates" : "应用更新"} {english - ? "EdgeEver automatically checks for compatible in-app updates and newer installable versions." - : "EdgeEver 会自动检查兼容的应用内热更新和新版安装包。"} + ? "EdgeEver automatically checks for compatible in-app updates and newer app versions." + : "EdgeEver 会自动检查兼容的应用内热更新和新版应用。"} {english ? "Current version" : "当前版本"}: v{Updates.runtimeVersion ?? Constants.expoConfig?.version ?? "unknown"} @@ -50,7 +46,7 @@ export const MobileUpdateCard = () => { onPress={() => void openUpdate()} style={[styles.button, busy && styles.buttonDisabled]} > - {status === "downloading" || status === "installing" ? : } + {status === "downloading" ? : } {openLabel} ) : null} diff --git a/apps/mobile/src/lib/android-apk-update.test.ts b/apps/mobile/src/lib/android-apk-update.test.ts deleted file mode 100644 index ce069bcf..00000000 --- a/apps/mobile/src/lib/android-apk-update.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { beforeEach, expect, mock, test } from "bun:test"; -import type { MobileRelease } from "./mobile-release"; - -const cachedFileSizes = new Map(); -const installerCalls: string[] = []; -let downloadCalls = 0; -let installerResult = true; - -class MockFile { - readonly uri: string; - - constructor(directory: string, fileName: string) { - this.uri = `${directory}/${fileName}`; - } - - get exists() { - return cachedFileSizes.has(this.uri); - } - - get size() { - return cachedFileSizes.get(this.uri) ?? 0; - } - - delete() { - cachedFileSizes.delete(this.uri); - } - - static async downloadFileAsync( - _url: string, - destination: MockFile, - options: { onProgress: (progress: { bytesWritten: number; totalBytes: number }) => void } - ) { - downloadCalls += 1; - options.onProgress({ bytesWritten: 24_000_000, totalBytes: 48_000_000 }); - cachedFileSizes.set(destination.uri, 48_000_000); - return destination; - } -} - -mock.module("expo-file-system", () => ({ - File: MockFile, - Paths: { cache: "file:///cache" }, -})); - -mock.module("../../modules/edgeever-app-installer", () => ({ - installAndroidApk: async (fileUri: string) => { - installerCalls.push(fileUri); - return installerResult; - }, -})); - -const { downloadAndroidApk, installDownloadedAndroidApk } = await import("./android-apk-update"); - -const release: MobileRelease = { - digest: `sha256:${"a".repeat(64)}`, - downloadUrl: "https://github.com/tianma-if/edgeever/releases/download/v1.40.0/edgeever-android-v1.40.0-arm64-v8a.apk", - fileName: "edgeever-android-v1.40.0-arm64-v8a.apk", - size: 48_000_000, - version: "1.40.0", -}; - -beforeEach(() => { - cachedFileSizes.clear(); - installerCalls.length = 0; - downloadCalls = 0; - installerResult = true; -}); - -test("downloads the APK without opening the installer", async () => { - const progress: number[] = []; - const fileUri = await downloadAndroidApk(release, ({ progress: value }) => progress.push(value)); - - expect(fileUri).toBe(`file:///cache/${release.fileName}`); - expect(downloadCalls).toBe(1); - expect(installerCalls).toEqual([]); - expect(progress).toEqual([0.5, 1]); -}); - -test("reuses a complete cached APK", async () => { - const cachedUri = `file:///cache/${release.fileName}`; - cachedFileSizes.set(cachedUri, release.size); - const progress: number[] = []; - - expect(await downloadAndroidApk(release, ({ progress: value }) => progress.push(value))).toBe(cachedUri); - expect(downloadCalls).toBe(0); - expect(progress).toEqual([1]); -}); - -test("opens the installer only after the prepared APK is accepted", async () => { - const fileUri = `file:///cache/${release.fileName}`; - - await installDownloadedAndroidApk(fileUri); - expect(installerCalls).toEqual([fileUri]); - - installerResult = false; - await expect(installDownloadedAndroidApk(fileUri)).rejects.toThrow("Permission to install apps was not granted"); -}); diff --git a/apps/mobile/src/lib/android-apk-update.ts b/apps/mobile/src/lib/android-apk-update.ts deleted file mode 100644 index e0998fb9..00000000 --- a/apps/mobile/src/lib/android-apk-update.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { MobileRelease } from "./mobile-release"; -import { installAndroidApk } from "../../modules/edgeever-app-installer"; - -export type AndroidApkDownloadProgress = { - downloadedBytes: number; - progress: number; - totalBytes: number; -}; - -export const downloadAndroidApk = async ( - release: MobileRelease, - onProgress: (progress: AndroidApkDownloadProgress) => void -) => { - const { File, Paths } = await import("expo-file-system"); - const destination = new File(Paths.cache, release.fileName); - - if (destination.exists && destination.size === release.size) { - onProgress({ downloadedBytes: release.size, progress: 1, totalBytes: release.size }); - return destination.uri; - } - - if (destination.exists) { - destination.delete(); - } - - 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 }); - return downloadedFile.uri; - } catch (error) { - if (destination.exists) { - destination.delete(); - } - throw error; - } -}; - -export const installDownloadedAndroidApk = async (fileUri: string) => { - const installerOpened = await installAndroidApk(fileUri); - if (!installerOpened) { - throw new Error("Permission to install apps was not granted"); - } -}; diff --git a/apps/mobile/src/lib/mobile-release.test.ts b/apps/mobile/src/lib/mobile-release.test.ts index 93f43762..5c0efa26 100644 --- a/apps/mobile/src/lib/mobile-release.test.ts +++ b/apps/mobile/src/lib/mobile-release.test.ts @@ -2,8 +2,9 @@ import { expect, test } from "bun:test"; import { ANDROID_INSTALL_UPDATE_SOURCES, findNewerMobileRelease, - getDefaultMobileInstallUpdateUrl, GITHUB_LATEST_RELEASE_URL, + GOOGLE_PLAY_APP_URL, + GOOGLE_PLAY_WEB_URL, } from "./mobile-release"; const responseWithTag = (tagName: string, androidVersion = tagName.replace(/^v/, "")) => new Response(JSON.stringify({ @@ -69,8 +70,20 @@ test("rejects invalid release responses instead of claiming the app is current", })))).rejects.toThrow("exactly one Android APK"); }); -test("uses GitHub Releases as the only install update destination", () => { - expect(getDefaultMobileInstallUpdateUrl("android")).toBe(GITHUB_LATEST_RELEASE_URL); - expect(getDefaultMobileInstallUpdateUrl("ios")).toBe(GITHUB_LATEST_RELEASE_URL); - expect(ANDROID_INSTALL_UPDATE_SOURCES.map((source) => source.url)).toEqual([GITHUB_LATEST_RELEASE_URL]); +test("offers Google Play and the latest GitHub Release as manual update destinations", () => { + expect(ANDROID_INSTALL_UPDATE_SOURCES).toEqual([ + { + fallbackUrl: GOOGLE_PLAY_WEB_URL, + id: "google-play", + labelEn: "Update on Google Play", + labelZh: "在 Google Play 更新", + url: GOOGLE_PLAY_APP_URL, + }, + { + id: "github", + labelEn: "Download from GitHub", + labelZh: "从 GitHub 下载", + url: GITHUB_LATEST_RELEASE_URL, + }, + ]); }); diff --git a/apps/mobile/src/lib/mobile-release.ts b/apps/mobile/src/lib/mobile-release.ts index 56ff0a37..06901961 100644 --- a/apps/mobile/src/lib/mobile-release.ts +++ b/apps/mobile/src/lib/mobile-release.ts @@ -3,28 +3,34 @@ import { clean, gt, valid } from "semver"; const LATEST_RELEASE_API_URL = "https://api.github.com/repos/tianma-if/edgeever/releases/latest"; export const GITHUB_LATEST_RELEASE_URL = "https://github.com/tianma-if/edgeever/releases/latest"; +export const GOOGLE_PLAY_APP_URL = "market://details?id=org.edgeever.mobile"; +export const GOOGLE_PLAY_WEB_URL = "https://play.google.com/store/apps/details?id=org.edgeever.mobile"; export type MobileInstallUpdateSource = { - id: "github"; + fallbackUrl?: string; + id: "github" | "google-play"; labelEn: string; labelZh: string; url: string; }; -/** GitHub Releases is the canonical source for the latest Android installable build. */ +/** Manual destinations presented when a newer Android binary is available. */ export const ANDROID_INSTALL_UPDATE_SOURCES: readonly MobileInstallUpdateSource[] = [ + { + fallbackUrl: GOOGLE_PLAY_WEB_URL, + id: "google-play", + labelEn: "Update on Google Play", + labelZh: "在 Google Play 更新", + url: GOOGLE_PLAY_APP_URL, + }, { id: "github", - labelEn: "GitHub Releases", - labelZh: "GitHub Releases", + labelEn: "Download from GitHub", + labelZh: "从 GitHub 下载", url: GITHUB_LATEST_RELEASE_URL, }, ]; -export const getDefaultMobileInstallUpdateUrl = (platform: "android" | "ios" | "web" | "windows" | "macos") => { - return GITHUB_LATEST_RELEASE_URL; -}; - type LatestReleaseResponse = { assets?: unknown; tag_name?: unknown; diff --git a/apps/mobile/src/lib/mobile-update.tsx b/apps/mobile/src/lib/mobile-update.tsx index e82822a9..80dc1920 100644 --- a/apps/mobile/src/lib/mobile-update.tsx +++ b/apps/mobile/src/lib/mobile-update.tsx @@ -1,20 +1,23 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import Constants from "expo-constants"; -import { AppState, Platform, type AppStateStatus } from "react-native"; +import { AppState, Linking, Platform, type AppStateStatus } from "react-native"; import * as Updates from "expo-updates"; import { Alert } from "../components/LocalizedText"; import { useMobileLocale } from "./mobile-locale"; -import { downloadAndroidApk, installDownloadedAndroidApk } from "./android-apk-update"; -import { findNewerMobileRelease, type MobileRelease } from "./mobile-release"; +import { + ANDROID_INSTALL_UPDATE_SOURCES, + findNewerMobileRelease, + type MobileInstallUpdateSource, + type MobileRelease, +} from "./mobile-release"; const FOREGROUND_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; -type MobileUpdateStatus = "idle" | "checking" | "available" | "downloading" | "ready" | "installing"; +type MobileUpdateStatus = "idle" | "checking" | "available" | "downloading" | "ready"; export type MobileUpdateKind = "install" | "ota"; type MobileUpdateContextValue = { checkForUpdate: () => Promise; - downloadProgress: number | null; hasUpdate: boolean; installedVersion: string | null; isSupported: boolean; @@ -25,7 +28,6 @@ type MobileUpdateContextValue = { const MobileUpdateContext = createContext({ checkForUpdate: async () => undefined, - downloadProgress: null, hasUpdate: false, installedVersion: null, isSupported: false, @@ -33,100 +35,64 @@ const MobileUpdateContext = createContext({ status: "idle", updateKind: null, }); +const MobileUpdateAvailableContext = createContext(false); export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { const { resolvedLocale } = useMobileLocale(); - const [downloadProgress, setDownloadProgress] = useState(null); - const [downloadedInstallFileUri, setDownloadedInstallFileUri] = useState(null); const [installRelease, setInstallRelease] = useState(null); const [status, setStatus] = useState("idle"); const [updateKind, setUpdateKind] = useState(null); const activeCheckRef = useRef | null>(null); - const activeDownloadRef = useRef | null>(null); const lastAutomaticCheckRef = useRef(0); - const pendingInstallPromptRef = useRef<{ fileUri: string; release: MobileRelease } | null>(null); - const promptedInstallVersionRef = useRef(null); const isSupported = !__DEV__ && Updates.isEnabled; const english = resolvedLocale === "en-US"; const installedVersion = Updates.runtimeVersion ?? Constants.expoConfig?.version ?? null; - const openPreparedInstaller = useCallback((release: MobileRelease, fileUri: string) => { + const openManualUpdateSource = useCallback((source: MobileInstallUpdateSource) => { + void Linking.openURL(source.url).catch(() => { + if (source.fallbackUrl) { + void Linking.openURL(source.fallbackUrl).catch(() => { + Alert.alert( + english ? "Could not open link" : "无法打开链接", + english ? "Check your browser settings and try again." : "请检查浏览器设置后重试。" + ); + }); + return; + } + Alert.alert( + english ? "Could not open link" : "无法打开链接", + english ? "Check your browser settings and try again." : "请检查浏览器设置后重试。" + ); + }); + }, [english]); + + const openInstallUpdateOptions = useCallback((release: MobileRelease) => { + const googlePlay = ANDROID_INSTALL_UPDATE_SOURCES.find((source) => source.id === "google-play"); + const github = ANDROID_INSTALL_UPDATE_SOURCES.find((source) => source.id === "github"); + if (!googlePlay || !github) { + return; + } Alert.alert( - english ? "Update ready" : "更新已就绪", + english ? "Update available" : "发现新版本", english - ? `EdgeEver ${release.version} has been downloaded and is ready to install.` - : `EdgeEver ${release.version} 安装包已下载完成,可以立即安装。`, + ? `EdgeEver ${release.version} is available. Choose where to update.` + : `EdgeEver ${release.version} 已发布,请选择更新渠道。`, [ { text: english ? "Later" : "稍后", style: "cancel", }, { - text: english ? "Install now" : "立即安装", - onPress: () => { - setStatus("installing"); - void installDownloadedAndroidApk(fileUri).then(() => { - setStatus("ready"); - }).catch(() => { - setStatus("ready"); - Alert.alert( - english ? "Could not open installer" : "无法打开安装器", - english - ? "Allow EdgeEver to install apps when prompted, then try again." - : "请在系统提示时允许 EdgeEver 安装应用,然后重试。" - ); - }); - }, + text: english ? github.labelEn : github.labelZh, + onPress: () => openManualUpdateSource(github), + }, + { + text: english ? googlePlay.labelEn : googlePlay.labelZh, + onPress: () => openManualUpdateSource(googlePlay), }, ] ); - }, [english]); - - const promptPreparedInstallerWhenActive = useCallback((release: MobileRelease, fileUri: string) => { - if (promptedInstallVersionRef.current === release.version) { - return; - } - if (AppState.currentState !== "active") { - pendingInstallPromptRef.current = { fileUri, release }; - return; - } - pendingInstallPromptRef.current = null; - promptedInstallVersionRef.current = release.version; - openPreparedInstaller(release, fileUri); - }, [openPreparedInstaller]); - - const prepareInstallRelease = useCallback(async (release: MobileRelease, promptWhenReady: boolean) => { - setInstallRelease(release); - setUpdateKind("install"); - - let download = activeDownloadRef.current; - if (!download) { - setDownloadProgress(0); - setStatus("downloading"); - download = downloadAndroidApk(release, ({ progress }) => setDownloadProgress(progress)); - activeDownloadRef.current = download; - } - - try { - const fileUri = await download; - setDownloadedInstallFileUri(fileUri); - setDownloadProgress(null); - setStatus("ready"); - if (promptWhenReady) { - promptPreparedInstallerWhenActive(release, fileUri); - } - return fileUri; - } catch (error) { - setDownloadedInstallFileUri(null); - setDownloadProgress(null); - setStatus("available"); - throw error; - } finally { - if (activeDownloadRef.current === download) { - activeDownloadRef.current = null; - } - } - }, [promptPreparedInstallerWhenActive]); + }, [english, openManualUpdateSource]); const runCheck = useCallback((userInitiated: boolean) => { if (activeCheckRef.current) { @@ -156,18 +122,9 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { } const release = await findNewerMobileRelease(installedVersion); if (release) { - try { - await prepareInstallRelease(release, true); - } catch { - if (userInitiated) { - Alert.alert( - english ? "Download failed" : "下载失败", - english - ? "Could not prepare the update package. Check your connection and try again." - : "无法准备更新安装包,请检查网络后重试。" - ); - } - } + setInstallRelease(release); + setUpdateKind("install"); + setStatus("available"); return; } } catch { @@ -198,7 +155,7 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { activeCheckRef.current = null; }); return check; - }, [english, installedVersion, isSupported, prepareInstallRelease]); + }, [english, installedVersion, isSupported]); useEffect(() => { const attemptAutomaticCheck = () => { @@ -211,10 +168,6 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { const timer = setTimeout(attemptAutomaticCheck, 1_500); const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => { if (nextState === "active") { - const pendingPrompt = pendingInstallPromptRef.current; - if (pendingPrompt) { - promptPreparedInstallerWhenActive(pendingPrompt.release, pendingPrompt.fileUri); - } attemptAutomaticCheck(); } }); @@ -223,25 +176,14 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { clearTimeout(timer); subscription.remove(); }; - }, [promptPreparedInstallerWhenActive, runCheck]); + }, [runCheck]); const openUpdate = useCallback(async () => { if (updateKind === "install") { - if (Platform.OS !== "android" || !installRelease || status === "downloading" || status === "installing") { + if (Platform.OS !== "android" || !installRelease) { return; } - if (downloadedInstallFileUri) { - openPreparedInstaller(installRelease, downloadedInstallFileUri); - return; - } - void prepareInstallRelease(installRelease, true).catch(() => { - Alert.alert( - english ? "Download failed" : "下载失败", - english - ? "Could not prepare the update package. Check your connection and try again." - : "无法准备更新安装包,请检查网络后重试。" - ); - }); + openInstallUpdateOptions(installRelease); return; } @@ -294,25 +236,30 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { : "无法下载应用内更新,请稍后再试。" ); } - }, [downloadedInstallFileUri, english, installRelease, isSupported, openPreparedInstaller, prepareInstallRelease, status, updateKind]); + }, [english, installRelease, isSupported, openInstallUpdateOptions, status, updateKind]); + const hasUpdate = status === "available" || status === "ready" || status === "downloading"; const value = useMemo( () => ({ checkForUpdate: () => { return runCheck(true); }, - downloadProgress, - hasUpdate: status === "available" || status === "ready" || status === "downloading" || status === "installing", + hasUpdate, installedVersion, isSupported, openUpdate, status, updateKind, }), - [downloadProgress, installedVersion, isSupported, openUpdate, runCheck, status, updateKind] + [hasUpdate, installedVersion, isSupported, openUpdate, runCheck, status, updateKind] ); - return {children}; + return ( + + {children} + + ); }; export const useMobileUpdate = () => useContext(MobileUpdateContext); +export const useMobileUpdateAvailable = () => useContext(MobileUpdateAvailableContext); diff --git a/apps/mobile/src/screens/WorkspaceScreen.tsx b/apps/mobile/src/screens/WorkspaceScreen.tsx index c02a1889..e8f03a6f 100644 --- a/apps/mobile/src/screens/WorkspaceScreen.tsx +++ b/apps/mobile/src/screens/WorkspaceScreen.tsx @@ -123,7 +123,7 @@ import { showEdgeEverKeyboard } from "../../modules/edgeever-keyboard"; import { MobileResourceActions } from "../components/MobileResourceActions"; import { MobileCreateChoiceModal, MobileTemplatePickerModal } from "../components/MobileTemplatePicker"; import { resolveMobileThemeStyles, useMobileTheme } from "../lib/mobile-theme"; -import { useMobileUpdate } from "../lib/mobile-update"; +import { useMobileUpdateAvailable } from "../lib/mobile-update"; import { createMemoSeedHasContent, type MobileCreateMemoSeed } from "../lib/mobile-templates"; import { createMobileDraftWriteBarrier } from "../lib/mobile-draft-write-barrier"; import { MobileMermaidDiagram, MobileMermaidProvider } from "../components/MobileMermaid"; @@ -294,6 +294,7 @@ export const WorkspaceScreen = ({ }) => { const { resolvedTheme } = useMobileTheme(); const { preference: localePreference, resolvedLocale, setPreference: setLocalePreference } = useMobileLocale(); + const hasUpdate = useMobileUpdateAvailable(); refreshWorkspaceThemeStyles(resolvedTheme); const { client, session, signOut } = useSession(); const queryClient = useQueryClient(); @@ -1623,6 +1624,7 @@ export const WorkspaceScreen = ({ } label="我的" onPress={openSettings} @@ -3593,9 +3595,18 @@ const ActionButton = ({ ); -const BottomNavItem = ({ active = false, icon, label, onPress }: { active?: boolean; icon: ReactNode; label: string; onPress: () => void }) => ( - - {icon} +const BottomNavItem = ({ active = false, badge = false, icon, label, onPress }: { active?: boolean; badge?: boolean; icon: ReactNode; label: string; onPress: () => void }) => ( + + + {icon} + {badge ? : null} + {label} ); diff --git a/apps/mobile/src/screens/workspace-styles.ts b/apps/mobile/src/screens/workspace-styles.ts index 0f9a5dde..00bb6d21 100644 --- a/apps/mobile/src/screens/workspace-styles.ts +++ b/apps/mobile/src/screens/workspace-styles.ts @@ -2221,6 +2221,20 @@ const baseWorkspaceStyles = StyleSheet.create({ minHeight: MOBILE_UI_METRICS.minimumTouchTarget, minWidth: 58, }, + bottomNavIcon: { + position: "relative", + }, + bottomNavBadge: { + backgroundColor: "#10b981", + borderColor: "#ffffff", + borderRadius: 5, + borderWidth: 1, + height: 9, + position: "absolute", + right: -5, + top: -3, + width: 9, + }, bottomNavText: { color: "#64748b", fontSize: 11, diff --git a/bun.lock b/bun.lock index c2c34dd6..67b2f6e6 100644 --- a/bun.lock +++ b/bun.lock @@ -138,7 +138,6 @@ "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", @@ -2316,8 +2315,6 @@ "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=="], diff --git a/package.json b/package.json index 7109e9bc..d86f9443 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "build:desktop": "EDGE_EVER_DESKTOP_BUILD=1 bun run prepare:desktop:icons && bun run build:web && bun run build:desktop:sidecar && bun run --cwd apps/desktop dist", "benchmark:desktop:sidecar": "bun scripts/benchmark-desktop-sidecar.mjs", "verify:web-performance": "bun scripts/verify-web-performance.mjs", - "test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/create-mac-update-metadata.test.mjs scripts/desktop-build-workflow.test.mjs scripts/report-native-build-timings.test.mjs scripts/release.test.mjs scripts/validate-store-delivery.test.mjs scripts/store-delivery.test.mjs scripts/configure-android-package-permissions.test.mjs scripts/download-play-universal-apk.test.mjs scripts/verify-android-apk-signature.test.mjs scripts/desktop-icns.test.mjs scripts/preview-macos-dock-icon.test.mjs apps/web/src/lib/version-check.test.mjs apps/mobile/src/lib/mobile-release.test.ts", + "test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/create-mac-update-metadata.test.mjs scripts/desktop-build-workflow.test.mjs scripts/report-native-build-timings.test.mjs scripts/release.test.mjs scripts/validate-store-delivery.test.mjs scripts/store-delivery.test.mjs scripts/download-play-universal-apk.test.mjs scripts/verify-android-apk-signature.test.mjs scripts/desktop-icns.test.mjs scripts/preview-macos-dock-icon.test.mjs apps/web/src/lib/version-check.test.mjs apps/mobile/src/lib/mobile-release.test.ts", "release": "bun scripts/release.mjs", "publish:stores": "bun scripts/store-delivery.mjs", "test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs", diff --git a/scripts/build-android-local.sh b/scripts/build-android-local.sh index dfa3393e..11959e64 100755 --- a/scripts/build-android-local.sh +++ b/scripts/build-android-local.sh @@ -69,17 +69,6 @@ else echo "跳过 Android prebuild:原生配置未变化。" fi -ANDROID_MANIFEST="$ANDROID_DIR/app/src/main/AndroidManifest.xml" -if [[ "$MODE" == "play" ]]; then - run_timed "移除 Play 不需要的侧载安装权限" \ - node "$PROJECT_ROOT/scripts/configure-android-package-permissions.mjs" \ - "$ANDROID_MANIFEST" play -else - run_timed "保留 APK 自更新所需的安装权限" \ - node "$PROJECT_ROOT/scripts/configure-android-package-permissions.mjs" \ - "$ANDROID_MANIFEST" sideload -fi - cd "$ANDROID_DIR" COMMON_ARGS=( --build-cache diff --git a/scripts/configure-android-package-permissions.mjs b/scripts/configure-android-package-permissions.mjs deleted file mode 100644 index 829aafe6..00000000 --- a/scripts/configure-android-package-permissions.mjs +++ /dev/null @@ -1,57 +0,0 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { pathToFileURL } from "node:url"; - -export const INSTALL_PACKAGES_PERMISSION = - "android.permission.REQUEST_INSTALL_PACKAGES"; - -export function configurePackagePermissions(manifest, target) { - if (target !== "play" && target !== "sideload") { - throw new Error(`Unsupported Android distribution target: ${target}`); - } - - const permissionPattern = new RegExp( - `^[\\t ]*]*\\/?>[\\t ]*\\r?\\n?`, - "gm", - ); - const withoutInstallPermission = manifest.replace(permissionPattern, ""); - - if (target === "play") { - return withoutInstallPermission; - } - - const manifestOpenTag = /]*>\r?\n/; - if (!manifestOpenTag.test(withoutInstallPermission)) { - throw new Error("Android manifest is missing its opening tag"); - } - - return withoutInstallPermission.replace( - manifestOpenTag, - (openTag) => - `${openTag} \n`, - ); -} - -export async function configureManifestFile(manifestPath, target) { - const original = await readFile(manifestPath, "utf8"); - const configured = configurePackagePermissions(original, target); - if (configured !== original) { - await writeFile(manifestPath, configured); - } -} - -async function main(argv) { - const [manifestPath, target] = argv; - if (!manifestPath || !target) { - throw new Error( - "Usage: node scripts/configure-android-package-permissions.mjs ", - ); - } - await configureManifestFile(manifestPath, target); -} - -if (import.meta.url === pathToFileURL(process.argv[1]).href) { - main(process.argv.slice(2)).catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; - }); -} diff --git a/scripts/configure-android-package-permissions.test.mjs b/scripts/configure-android-package-permissions.test.mjs deleted file mode 100644 index 39ffad26..00000000 --- a/scripts/configure-android-package-permissions.test.mjs +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - configurePackagePermissions, - INSTALL_PACKAGES_PERMISSION, -} from "./configure-android-package-permissions.mjs"; - -const baseManifest = ` - - - -`; - -describe("Android package permission configuration", () => { - test("removes the sideload-only install permission from Play builds", () => { - const configured = configurePackagePermissions( - baseManifest.replace( - " \n { - const configured = configurePackagePermissions(baseManifest, "sideload"); - - expect(configured).toContain(INSTALL_PACKAGES_PERMISSION); - }); - - test("keeps sideload configuration idempotent", () => { - const configured = configurePackagePermissions(baseManifest, "sideload"); - const configuredAgain = configurePackagePermissions(configured, "sideload"); - - expect(configuredAgain).toBe(configured); - expect(configuredAgain.match(/REQUEST_INSTALL_PACKAGES/g)).toHaveLength(1); - }); -}); diff --git a/scripts/plan-native-release.mjs b/scripts/plan-native-release.mjs index 53a61ddc..61fd3b63 100644 --- a/scripts/plan-native-release.mjs +++ b/scripts/plan-native-release.mjs @@ -43,7 +43,6 @@ export const planNativeRelease = (platform, changedFiles) => { ".github/workflows/store-delivery.yml", "bun.lock", "scripts/build-android-local.sh", - "scripts/configure-android-package-permissions.mjs", "scripts/download-play-universal-apk.mjs", "scripts/verify-android-apk-signature.mjs", ...mobileOnlyDependencyPatches, diff --git a/scripts/plan-native-release.test.mjs b/scripts/plan-native-release.test.mjs index 6c215c14..d273bc79 100644 --- a/scripts/plan-native-release.test.mjs +++ b/scripts/plan-native-release.test.mjs @@ -19,7 +19,6 @@ describe("native release planning", () => { "packages/shared/src/index.ts", "bun.lock", "scripts/build-android-local.sh", - "scripts/configure-android-package-permissions.mjs", "scripts/verify-android-apk-signature.mjs", ".github/workflows/mobile-build.yml", ".github/workflows/android-play-signature-audit.yml", diff --git a/scripts/release.mjs b/scripts/release.mjs index 4ab08a2f..925833a0 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -45,7 +45,6 @@ export const RELEASE_VALIDATIONS = [ "scripts/release.test.mjs", "scripts/validate-store-delivery.test.mjs", "scripts/store-delivery.test.mjs", - "scripts/configure-android-package-permissions.test.mjs", "scripts/download-play-universal-apk.test.mjs", "scripts/desktop-icns.test.mjs", "apps/web/src/lib/version-check.test.mjs", diff --git a/scripts/store-delivery.test.mjs b/scripts/store-delivery.test.mjs index b3393fe5..05cf0937 100644 --- a/scripts/store-delivery.test.mjs +++ b/scripts/store-delivery.test.mjs @@ -155,15 +155,18 @@ describe("store delivery command", () => { expect(buildScript).not.toContain("armeabi-v7a,arm64-v8a,x86,x86_64"); }); - test("removes the sideload-only install permission from Play bundles", () => { + test("keeps the self-update install permission out of Android builds", () => { const buildScript = readFileSync( new URL("./build-android-local.sh", import.meta.url), "utf8", ); + const appConfig = readFileSync( + new URL("../apps/mobile/app.json", import.meta.url), + "utf8", + ); - expect(buildScript).toContain('if [[ "$MODE" == "play" ]]'); - expect(buildScript).toContain('"$ANDROID_MANIFEST" play'); - expect(buildScript).toContain('"$ANDROID_MANIFEST" sideload'); + expect(appConfig).not.toContain("android.permission.REQUEST_INSTALL_PACKAGES"); + expect(buildScript).not.toContain("configure-android-package-permissions"); expect(buildScript).toContain( 'grep -q "android.permission.REQUEST_INSTALL_PACKAGES" "$PACKAGED_MANIFEST"', );