Support F-Droid Android builds (#1768)
* fix(android): support source-based APK builds Co-authored-by: Anton Lazarev <22821309+antonok-edm@users.noreply.github.com> * fix(android): address fdroid review feedback Co-authored-by: Anton Lazarev <22821309+antonok-edm@users.noreply.github.com> * fix(android): complete the F-Droid build profile * fix(app): isolate the F-Droid runtime flag --------- Co-authored-by: Anton Lazarev <22821309+antonok-edm@users.noreply.github.com>
This commit is contained in:
co-authored by
Anton Lazarev
parent
1f7efbd185
commit
b6d49da4df
+81
-17
@@ -1,7 +1,75 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const pkg = require("./package.json");
|
||||
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
||||
const appVariant = process.env.APP_VARIANT ?? "production";
|
||||
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
||||
|
||||
const buildProfile = isFdroidBuild
|
||||
? {
|
||||
androidPermissions: [
|
||||
"RECORD_AUDIO",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.MODIFY_AUDIO_SETTINGS",
|
||||
],
|
||||
cameraPlugins: [],
|
||||
fdroidPlugins: [withFdroidAutolinking],
|
||||
notificationPlugins: [],
|
||||
updates: { enabled: false },
|
||||
}
|
||||
: {
|
||||
androidPermissions: [
|
||||
"RECORD_AUDIO",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.MODIFY_AUDIO_SETTINGS",
|
||||
"CAMERA",
|
||||
"android.permission.CAMERA",
|
||||
],
|
||||
cameraPlugins: [
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
cameraPermission:
|
||||
"Allow $(PRODUCT_NAME) to access your camera to scan pairing QR codes.",
|
||||
},
|
||||
],
|
||||
],
|
||||
fdroidPlugins: [],
|
||||
notificationPlugins: [
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
icon: "./assets/images/notification-icon.png",
|
||||
color: "#20744A",
|
||||
},
|
||||
],
|
||||
],
|
||||
updates: {},
|
||||
};
|
||||
|
||||
function getNativeBuildVersionCode(version) {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version);
|
||||
if (!match) {
|
||||
throw new Error(`Cannot derive Android versionCode from non-semver version: ${version}`);
|
||||
}
|
||||
|
||||
const [, majorText, minorText, patchText] = match;
|
||||
const major = Number(majorText);
|
||||
const minor = Number(minorText);
|
||||
const patch = Number(patchText);
|
||||
|
||||
if (minor > 999 || patch > 999) {
|
||||
throw new Error(`Cannot derive collision-free Android versionCode from version: ${version}`);
|
||||
}
|
||||
|
||||
const versionCode = major * 1_000_000 + minor * 1_000 + patch;
|
||||
|
||||
if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || versionCode > 2_100_000_000) {
|
||||
throw new Error(`Derived Android versionCode is out of range: ${versionCode}`);
|
||||
}
|
||||
|
||||
return versionCode;
|
||||
}
|
||||
|
||||
function resolveSecretFile(params) {
|
||||
const fromEnv = process.env[params.envKey];
|
||||
@@ -45,6 +113,7 @@ const variants = {
|
||||
};
|
||||
|
||||
const variant = variants[appVariant] ?? variants.production;
|
||||
const nativeBuildVersionCode = getNativeBuildVersionCode(pkg.version);
|
||||
|
||||
export default {
|
||||
expo: {
|
||||
@@ -61,6 +130,7 @@ export default {
|
||||
},
|
||||
updates: {
|
||||
url: "https://u.expo.dev/0e7f65ce-0367-46c8-a238-2b65963d235a",
|
||||
...buildProfile.updates,
|
||||
},
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
@@ -72,6 +142,7 @@ export default {
|
||||
...(variant.googleServiceInfoPlist
|
||||
? { googleServicesFile: variant.googleServiceInfoPlist }
|
||||
: {}),
|
||||
buildNumber: String(nativeBuildVersionCode),
|
||||
},
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
@@ -83,14 +154,9 @@ export default {
|
||||
softwareKeyboardLayoutMode: "resize",
|
||||
// Allow HTTP connections for local network hosts (required for release builds)
|
||||
usesCleartextTraffic: true,
|
||||
permissions: [
|
||||
"RECORD_AUDIO",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.MODIFY_AUDIO_SETTINGS",
|
||||
"CAMERA",
|
||||
"android.permission.CAMERA",
|
||||
],
|
||||
permissions: buildProfile.androidPermissions,
|
||||
package: variant.packageId,
|
||||
versionCode: nativeBuildVersionCode,
|
||||
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
|
||||
},
|
||||
web: {
|
||||
@@ -102,12 +168,7 @@ export default {
|
||||
},
|
||||
plugins: [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
cameraPermission: "Allow $(PRODUCT_NAME) to access your camera to scan pairing QR codes.",
|
||||
},
|
||||
],
|
||||
...buildProfile.cameraPlugins,
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
@@ -120,14 +181,15 @@ export default {
|
||||
},
|
||||
},
|
||||
],
|
||||
...buildProfile.notificationPlugins,
|
||||
"expo-audio",
|
||||
[
|
||||
"expo-notifications",
|
||||
"expo-gradle-jvmargs",
|
||||
{
|
||||
icon: "./assets/images/notification-icon.png",
|
||||
color: "#20744A",
|
||||
xmx: "4096m",
|
||||
maxMetaspace: "1024m",
|
||||
},
|
||||
],
|
||||
"expo-audio",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
@@ -139,6 +201,7 @@ export default {
|
||||
},
|
||||
},
|
||||
],
|
||||
...buildProfile.fdroidPlugins,
|
||||
],
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
@@ -146,6 +209,7 @@ export default {
|
||||
autolinkingModuleResolution: true,
|
||||
},
|
||||
extra: {
|
||||
fdroidBuild: isFdroidBuild,
|
||||
router: {},
|
||||
eas: {
|
||||
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 14.0.0",
|
||||
"appVersionSource": "remote"
|
||||
"appVersionSource": "local"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
@@ -19,9 +19,6 @@
|
||||
"channel": "production",
|
||||
"env": {
|
||||
"APP_VARIANT": "production"
|
||||
},
|
||||
"android": {
|
||||
"autoIncrement": "versionCode"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
|
||||
@@ -7,6 +7,11 @@ const projectRoot = __dirname;
|
||||
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
|
||||
const appSrcRoot = path.resolve(projectRoot, "src");
|
||||
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
|
||||
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
||||
const fdroidModuleOverrides = {
|
||||
"expo-camera": path.resolve(appSrcRoot, "fdroid/expo-camera.tsx"),
|
||||
"expo-notifications": path.resolve(appSrcRoot, "fdroid/expo-notifications.ts"),
|
||||
};
|
||||
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||
.trim()
|
||||
.replace(/^\./, "")
|
||||
@@ -72,6 +77,10 @@ function resolveWithCustomWebOverlay(context, moduleName, platform) {
|
||||
}
|
||||
|
||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
if (isFdroidBuild && platform === "android" && fdroidModuleOverrides[moduleName]) {
|
||||
return resolveWithCustomWebOverlay(context, fdroidModuleOverrides[moduleName], platform);
|
||||
}
|
||||
|
||||
const origin = context.originModulePath;
|
||||
if (origin && origin.startsWith(relaySrcRoot) && moduleName.endsWith(".js")) {
|
||||
const tsModuleName = moduleName.replace(/\.js$/, ".ts");
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
"eas-cli": "^16.24.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"expo-gradle-jvmargs": "^1.1.2",
|
||||
"jsdom": "^20.0.3",
|
||||
"material-icon-theme": "^5.32.0",
|
||||
"playwright": "^1.56.1",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { withAppBuildGradle, withDangerousMod, withSettingsGradle } = require("expo/config-plugins");
|
||||
|
||||
const EXCLUDED_ANDROID_MODULES = [
|
||||
"expo-camera",
|
||||
"expo-notifications",
|
||||
"expo-dev-client",
|
||||
"expo-dev-launcher",
|
||||
"expo-dev-menu",
|
||||
"expo-dev-menu-interface",
|
||||
];
|
||||
|
||||
function withFdroidAutolinking(config) {
|
||||
config = withDangerousMod(config, [
|
||||
"android",
|
||||
async (modConfig) => {
|
||||
const packageJsonPath = path.join(modConfig.modRequest.projectRoot, "package.json");
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
|
||||
const expo = packageJson.expo ?? {};
|
||||
const autolinking = expo.autolinking ?? {};
|
||||
const android = autolinking.android ?? {};
|
||||
const fdroidPackageJson = {
|
||||
...packageJson,
|
||||
expo: {
|
||||
...expo,
|
||||
autolinking: {
|
||||
...autolinking,
|
||||
android: {
|
||||
...android,
|
||||
buildFromSource: [".*"],
|
||||
exclude: EXCLUDED_ANDROID_MODULES,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const overlayRoot = path.join(modConfig.modRequest.platformProjectRoot, "fdroid-autolinking");
|
||||
|
||||
await fs.mkdir(overlayRoot, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(overlayRoot, "package.json"),
|
||||
`${JSON.stringify(fdroidPackageJson, null, 2)}\n`,
|
||||
);
|
||||
return modConfig;
|
||||
},
|
||||
]);
|
||||
|
||||
config = withSettingsGradle(config, (modConfig) => {
|
||||
const fdroidProjectRoot =
|
||||
'expoAutolinking.projectRoot = new File(rootDir, "fdroid-autolinking")';
|
||||
if (modConfig.modResults.contents.includes(fdroidProjectRoot)) {
|
||||
return modConfig;
|
||||
}
|
||||
|
||||
const useExpoModules = "expoAutolinking.useExpoModules()";
|
||||
if (!modConfig.modResults.contents.includes(useExpoModules)) {
|
||||
throw new Error("Could not configure F-Droid Expo autolinking in settings.gradle");
|
||||
}
|
||||
|
||||
modConfig.modResults.contents = modConfig.modResults.contents.replace(
|
||||
useExpoModules,
|
||||
`${fdroidProjectRoot}\n${useExpoModules}`,
|
||||
);
|
||||
return modConfig;
|
||||
});
|
||||
|
||||
return withAppBuildGradle(config, (modConfig) => {
|
||||
if (modConfig.modResults.contents.includes("dependenciesInfo {")) {
|
||||
return modConfig;
|
||||
}
|
||||
|
||||
const androidBlock = "android {";
|
||||
if (!modConfig.modResults.contents.includes(androidBlock)) {
|
||||
throw new Error("Could not disable F-Droid dependency metadata in app/build.gradle");
|
||||
}
|
||||
|
||||
modConfig.modResults.contents = modConfig.modResults.contents.replace(
|
||||
androidBlock,
|
||||
`${androidBlock}\n dependenciesInfo {\n includeInApk = false\n includeInBundle = false\n }`,
|
||||
);
|
||||
return modConfig;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = withFdroidAutolinking;
|
||||
@@ -4,6 +4,7 @@ import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { isFdroidBuild } from "@/constants/build-profile";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -86,7 +87,7 @@ export function AddHostMethodModal({
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{isNative ? (
|
||||
{isNative && !isFdroidBuild ? (
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={handleScan}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isFdroidBuild } from "@/constants/build-profile";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
interface WelcomeAction {
|
||||
@@ -201,51 +202,52 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
[onHostAdded, finishOnboarding],
|
||||
);
|
||||
|
||||
const actions: WelcomeAction[] = isWeb
|
||||
? [
|
||||
{
|
||||
key: "direct-connection",
|
||||
label: t("pairing.connectionMethods.direct.title"),
|
||||
testID: "welcome-direct-connection",
|
||||
primary: true,
|
||||
icon: Link2,
|
||||
onPress: handleOpenDirect,
|
||||
},
|
||||
{
|
||||
key: "paste-pairing-link",
|
||||
label: t("pairing.connectionMethods.pasteLink.title"),
|
||||
testID: "welcome-paste-pairing-link",
|
||||
primary: false,
|
||||
icon: ClipboardPaste,
|
||||
onPress: handleOpenPasteLink,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: "scan-qr",
|
||||
label: t("pairing.connectionMethods.scanQr.title"),
|
||||
testID: "welcome-scan-qr",
|
||||
primary: true,
|
||||
icon: QrCode,
|
||||
onPress: handleScanQr,
|
||||
},
|
||||
{
|
||||
key: "direct-connection",
|
||||
label: t("pairing.connectionMethods.direct.title"),
|
||||
testID: "welcome-direct-connection",
|
||||
primary: false,
|
||||
icon: Link2,
|
||||
onPress: handleOpenDirect,
|
||||
},
|
||||
{
|
||||
key: "paste-pairing-link",
|
||||
label: t("pairing.connectionMethods.pasteLink.title"),
|
||||
testID: "welcome-paste-pairing-link",
|
||||
primary: false,
|
||||
icon: ClipboardPaste,
|
||||
onPress: handleOpenPasteLink,
|
||||
},
|
||||
];
|
||||
const actions: WelcomeAction[] =
|
||||
isWeb || isFdroidBuild
|
||||
? [
|
||||
{
|
||||
key: "direct-connection",
|
||||
label: t("pairing.connectionMethods.direct.title"),
|
||||
testID: "welcome-direct-connection",
|
||||
primary: true,
|
||||
icon: Link2,
|
||||
onPress: handleOpenDirect,
|
||||
},
|
||||
{
|
||||
key: "paste-pairing-link",
|
||||
label: t("pairing.connectionMethods.pasteLink.title"),
|
||||
testID: "welcome-paste-pairing-link",
|
||||
primary: false,
|
||||
icon: ClipboardPaste,
|
||||
onPress: handleOpenPasteLink,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: "scan-qr",
|
||||
label: t("pairing.connectionMethods.scanQr.title"),
|
||||
testID: "welcome-scan-qr",
|
||||
primary: true,
|
||||
icon: QrCode,
|
||||
onPress: handleScanQr,
|
||||
},
|
||||
{
|
||||
key: "direct-connection",
|
||||
label: t("pairing.connectionMethods.direct.title"),
|
||||
testID: "welcome-direct-connection",
|
||||
primary: false,
|
||||
icon: Link2,
|
||||
onPress: handleOpenDirect,
|
||||
},
|
||||
{
|
||||
key: "paste-pairing-link",
|
||||
label: t("pairing.connectionMethods.pasteLink.title"),
|
||||
testID: "welcome-paste-pairing-link",
|
||||
primary: false,
|
||||
icon: ClipboardPaste,
|
||||
onPress: handleOpenPasteLink,
|
||||
},
|
||||
];
|
||||
|
||||
const scrollContentContainerStyle = useMemo(
|
||||
() => [styles.container, { paddingBottom: theme.spacing[6] + insets.bottom }],
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import Constants from "expo-constants";
|
||||
|
||||
/** F-Droid build without proprietary camera, notification, or OTA dependencies. */
|
||||
export const isFdroidBuild = Constants.expoConfig?.extra?.fdroidBuild === true;
|
||||
@@ -0,0 +1,18 @@
|
||||
const DENIED_CAMERA_PERMISSION = {
|
||||
canAskAgain: false,
|
||||
expires: "never",
|
||||
granted: false,
|
||||
status: "denied",
|
||||
} as const;
|
||||
|
||||
async function requestCameraPermission() {
|
||||
return DENIED_CAMERA_PERMISSION;
|
||||
}
|
||||
|
||||
export function useCameraPermissions() {
|
||||
return [DENIED_CAMERA_PERMISSION, requestCameraPermission] as const;
|
||||
}
|
||||
|
||||
export function CameraView(): null {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
const DENIED_NOTIFICATION_PERMISSION = {
|
||||
canAskAgain: false,
|
||||
expires: "never",
|
||||
granted: false,
|
||||
status: "denied",
|
||||
} as const;
|
||||
|
||||
export const PermissionStatus = {
|
||||
DENIED: "denied",
|
||||
GRANTED: "granted",
|
||||
UNDETERMINED: "undetermined",
|
||||
} as const;
|
||||
|
||||
export const AndroidImportance = { DEFAULT: 3 } as const;
|
||||
|
||||
export async function getPermissionsAsync() {
|
||||
return DENIED_NOTIFICATION_PERMISSION;
|
||||
}
|
||||
|
||||
export async function requestPermissionsAsync() {
|
||||
return DENIED_NOTIFICATION_PERMISSION;
|
||||
}
|
||||
|
||||
export async function getExpoPushTokenAsync() {
|
||||
return { data: "" };
|
||||
}
|
||||
|
||||
export async function setNotificationChannelAsync() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setNotificationHandler(): void {}
|
||||
|
||||
export function addNotificationResponseReceivedListener() {
|
||||
return { remove() {} };
|
||||
}
|
||||
|
||||
export async function getLastNotificationResponseAsync() {
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user