fix(android): route app updates to trusted sources
This commit is contained in:
1 parent
c58ed12e47
commit
8548196054
24 files changed
+137
-570
No files matched your search
@@ -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'
|
||||
}
|
||||
}
|
||||
-110
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"',
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user