fix(android): route app updates to trusted sources

This commit is contained in:
tianma-if committed 2026-08-28 20:09:28 +08:00
1 parent c58ed12e47
commit 8548196054
24 files changed
+137 -570

No files matched your search

-3
View File
@@ -34,9 +34,6 @@
"android": {
"package": "org.edgeever.mobile",
"versionCode": 142,
"permissions": [
"android.permission.REQUEST_INSTALL_PACKAGES"
],
"adaptiveIcon": {
"backgroundColor": "#16A06E",
"backgroundImage": "./assets/icon.png",
@@ -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'
}
}
@@ -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)
}
}
@@ -1,6 +0,0 @@
{
"platforms": ["android"],
"android": {
"modules": ["org.edgeever.installer.EdgeEverAppInstallerModule"]
}
}
@@ -1,13 +0,0 @@
import { requireNativeModule } from "expo";
type EdgeEverAppInstallerNativeModule = {
install: (fileUri: string) => Promise<boolean>;
};
/**
* 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<EdgeEverAppInstallerNativeModule>("EdgeEverAppInstaller").install(fileUri);
};
@@ -1,6 +0,0 @@
{
"name": "edgeever-app-installer",
"version": "1.0.0",
"main": "index.ts",
"private": true
}
-1
View File
@@ -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",
@@ -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 (
<View style={styles.card}>
@@ -35,8 +31,8 @@ export const MobileUpdateCard = () => {
<Text style={styles.title}>{english ? "App updates" : "应用更新"}</Text>
<Text style={styles.description}>
{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 会自动检查兼容的应用内热更新和新版应用。"}
</Text>
<Text style={styles.version}>
{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" ? <ActivityIndicator color="#047857" size="small" /> : <RefreshCw color="#047857" size={16} />}
{status === "downloading" ? <ActivityIndicator color="#047857" size="small" /> : <RefreshCw color="#047857" size={16} />}
<Text style={styles.buttonText}>{openLabel}</Text>
</Pressable>
) : null}
@@ -1,97 +0,0 @@
import { beforeEach, expect, mock, test } from "bun:test";
import type { MobileRelease } from "./mobile-release";
const cachedFileSizes = new Map<string, number>();
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");
});
-58
View File
@@ -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");
}
};
+18 -5
View File
@@ -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,
},
]);
});
+14 -8
View File
@@ -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;
+60 -113
View File
@@ -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<void>;
downloadProgress: number | null;
hasUpdate: boolean;
installedVersion: string | null;
isSupported: boolean;
@@ -25,7 +28,6 @@ type MobileUpdateContextValue = {
const MobileUpdateContext = createContext<MobileUpdateContextValue>({
checkForUpdate: async () => undefined,
downloadProgress: null,
hasUpdate: false,
installedVersion: null,
isSupported: false,
@@ -33,100 +35,64 @@ const MobileUpdateContext = createContext<MobileUpdateContextValue>({
status: "idle",
updateKind: null,
});
const MobileUpdateAvailableContext = createContext(false);
export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => {
const { resolvedLocale } = useMobileLocale();
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [downloadedInstallFileUri, setDownloadedInstallFileUri] = useState<string | null>(null);
const [installRelease, setInstallRelease] = useState<MobileRelease | null>(null);
const [status, setStatus] = useState<MobileUpdateStatus>("idle");
const [updateKind, setUpdateKind] = useState<MobileUpdateKind | null>(null);
const activeCheckRef = useRef<Promise<void> | null>(null);
const activeDownloadRef = useRef<Promise<string> | null>(null);
const lastAutomaticCheckRef = useRef(0);
const pendingInstallPromptRef = useRef<{ fileUri: string; release: MobileRelease } | null>(null);
const promptedInstallVersionRef = useRef<string | null>(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<MobileUpdateContextValue>(
() => ({
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 <MobileUpdateContext.Provider value={value}>{children}</MobileUpdateContext.Provider>;
return (
<MobileUpdateAvailableContext.Provider value={hasUpdate}>
<MobileUpdateContext.Provider value={value}>{children}</MobileUpdateContext.Provider>
</MobileUpdateAvailableContext.Provider>
);
};
export const useMobileUpdate = () => useContext(MobileUpdateContext);
export const useMobileUpdateAvailable = () => useContext(MobileUpdateAvailableContext);
+15 -4
View File
@@ -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 = ({
</Pressable>
<BottomNavItem
active={false}
badge={hasUpdate}
icon={<UserRound color="#64748b" size={20} />}
label="我的"
onPress={openSettings}
@@ -3593,9 +3595,18 @@ const ActionButton = ({
</Pressable>
);
const BottomNavItem = ({ active = false, icon, label, onPress }: { active?: boolean; icon: ReactNode; label: string; onPress: () => void }) => (
<Pressable accessibilityRole="button" accessibilityState={{ selected: active }} onPress={onPress} style={styles.bottomNavItem}>
{icon}
const BottomNavItem = ({ active = false, badge = false, icon, label, onPress }: { active?: boolean; badge?: boolean; icon: ReactNode; label: string; onPress: () => void }) => (
<Pressable
accessibilityLabel={badge ? `${label},发现新版本` : label}
accessibilityRole="button"
accessibilityState={{ selected: active }}
onPress={onPress}
style={styles.bottomNavItem}
>
<View style={styles.bottomNavIcon}>
{icon}
{badge ? <View accessibilityElementsHidden importantForAccessibility="no-hide-descendants" style={styles.bottomNavBadge} /> : null}
</View>
<Text style={[styles.bottomNavText, active && styles.bottomNavTextActive]}>{label}</Text>
</Pressable>
);
@@ -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,
-3
View File
@@ -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=="],
+1 -1
View File
@@ -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",
-11
View File
@@ -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
@@ -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 ]*<uses-permission\\s+android:name=["']${INSTALL_PACKAGES_PERMISSION}["'][^>]*\\/?>[\\t ]*\\r?\\n?`,
"gm",
);
const withoutInstallPermission = manifest.replace(permissionPattern, "");
if (target === "play") {
return withoutInstallPermission;
}
const manifestOpenTag = /<manifest\b[^>]*>\r?\n/;
if (!manifestOpenTag.test(withoutInstallPermission)) {
throw new Error("Android manifest is missing its opening <manifest> tag");
}
return withoutInstallPermission.replace(
manifestOpenTag,
(openTag) =>
`${openTag} <uses-permission android:name="${INSTALL_PACKAGES_PERMISSION}"/>\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 <AndroidManifest.xml> <play|sideload>",
);
}
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;
});
}
@@ -1,40 +0,0 @@
import { describe, expect, test } from "bun:test";
import {
configurePackagePermissions,
INSTALL_PACKAGES_PERMISSION,
} from "./configure-android-package-permissions.mjs";
const baseManifest = `<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application android:name=".MainApplication"/>
</manifest>
`;
describe("Android package permission configuration", () => {
test("removes the sideload-only install permission from Play builds", () => {
const configured = configurePackagePermissions(
baseManifest.replace(
" <application",
` <uses-permission android:name="${INSTALL_PACKAGES_PERMISSION}"/>\n <application`,
),
"play",
);
expect(configured).not.toContain(INSTALL_PACKAGES_PERMISSION);
expect(configured).toContain("android.permission.INTERNET");
});
test("adds the install permission to sideload builds", () => {
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);
});
});
-1
View File
@@ -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,
-1
View File
@@ -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",
-1
View File
@@ -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",
+7 -4
View File
@@ -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"',
);