diff --git a/packages/app/app.config.js b/packages/app/app.config.js index 79d68249f2..e09a06f71d 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -2,6 +2,7 @@ const fs = require("node:fs"); const path = require("node:path"); const pkg = require("./package.json"); const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size"); +const withAndroidOptionalCamera = require("./plugins/with-android-optional-camera"); const withAndroidProfileable = require("./plugins/with-android-profileable"); const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking"); const withPasteInput = require("./plugins/with-paste-input"); @@ -145,6 +146,7 @@ export default { withAndroidScroll, [withAndroidAsyncStorageSize, 64], ...buildProfile.cameraPlugins, + withAndroidOptionalCamera, [ "expo-splash-screen", { diff --git a/packages/app/app.config.test.ts b/packages/app/app.config.test.ts new file mode 100644 index 0000000000..b98258ff9a --- /dev/null +++ b/packages/app/app.config.test.ts @@ -0,0 +1,61 @@ +import { createRequire } from "node:module"; +import { afterEach, describe, expect, it } from "vitest"; + +// Load the copies the Expo CLI uses for prebuild, not older hoisted ones. +const requireFromExpo = createRequire(require.resolve("expo/package.json")); +const requireFromExpoCli = createRequire(requireFromExpo.resolve("@expo/cli/package.json")); +const { getPrebuildConfigAsync } = requireFromExpoCli("@expo/prebuild-config"); +const { compileModsAsync } = requireFromExpoCli( + "@expo/config-plugins/build/plugins/mod-compiler.js", +); + +interface ManifestEntry { + $: Record; +} + +const originalFdroidFlag = process.env.PASEO_FDROID_BUILD; + +async function resolveAndroidManifest() { + const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["android"] }); + await compileModsAsync(exp, { + projectRoot: __dirname, + introspect: true, + platforms: ["android"], + assertMissingModProviders: false, + }); + return exp._internal.modResults.android.manifest.manifest; +} + +describe("Android app config", () => { + afterEach(() => { + if (originalFdroidFlag === undefined) { + delete process.env.PASEO_FDROID_BUILD; + } else { + process.env.PASEO_FDROID_BUILD = originalFdroidFlag; + } + }); + + it.each([ + ["Google Play", "0"], + ["F-Droid", "1"], + ])( + "%s build installs on devices without a camera", + async (_build, fdroidFlag) => { + process.env.PASEO_FDROID_BUILD = fdroidFlag; + const manifest = await resolveAndroidManifest(); + const permissions = (manifest["uses-permission"] ?? []).map( + (entry: ManifestEntry) => entry.$["android:name"], + ); + const features = (manifest["uses-feature"] ?? []).map((entry: ManifestEntry) => entry.$); + + expect(permissions).toContain("android.permission.CAMERA"); + expect(features).toEqual( + expect.arrayContaining([ + { "android:name": "android.hardware.camera", "android:required": "false" }, + { "android:name": "android.hardware.camera.autofocus", "android:required": "false" }, + ]), + ); + }, + 30_000, + ); +}); diff --git a/packages/app/plugins/with-android-optional-camera.js b/packages/app/plugins/with-android-optional-camera.js new file mode 100644 index 0000000000..dd5776e155 --- /dev/null +++ b/packages/app/plugins/with-android-optional-camera.js @@ -0,0 +1,24 @@ +const { withAndroidManifest } = require("expo/config-plugins"); + +// Requesting CAMERA implies these features are required, which hides the app +// on Google Play for devices without a camera. Pairing works without QR scanning. +const OPTIONAL_CAMERA_FEATURES = ["android.hardware.camera", "android.hardware.camera.autofocus"]; + +function withAndroidOptionalCamera(config) { + return withAndroidManifest(config, (modConfig) => { + const manifest = modConfig.modResults.manifest; + const features = manifest["uses-feature"] ?? []; + for (const name of OPTIONAL_CAMERA_FEATURES) { + const existing = features.find((feature) => feature.$["android:name"] === name); + if (existing) { + existing.$["android:required"] = "false"; + } else { + features.push({ $: { "android:name": name, "android:required": "false" } }); + } + } + manifest["uses-feature"] = features; + return modConfig; + }); +} + +module.exports = withAndroidOptionalCamera; diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 521fb6dbe0..3838948be9 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -22,7 +22,11 @@ export default defineConfig({ test: { name: "unit", environment: "node", - include: ["src/**/*.{test,spec}.{ts,tsx}", "native-release-version.test.ts"], + include: [ + "src/**/*.{test,spec}.{ts,tsx}", + "native-release-version.test.ts", + "app.config.test.ts", + ], setupFiles: [path.resolve(__dirname, "vitest.setup.ts")], exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"], },