import { ConfigPlugin, withAndroidManifest, withDangerousMod, withEntitlementsPlist, withXcodeProject, } from "expo/config-plugins"; import fs from "fs"; import path from "path"; import streamplaceReactNativeWebRTC from "../config-react-native-webrtc"; export const withNotificationsIOS: ConfigPlugin = (config) => { config = withEntitlementsPlist(config, (config) => { config.modResults["aps-environment"] = "production"; return config; }); return config; }; export const withoutNotificationsIOS: ConfigPlugin = (config) => { config = withEntitlementsPlist(config, (config) => { delete config.modResults["aps-environment"]; return config; }); return config; }; const withAndroidProfileable = (config) => { return withAndroidManifest(config, (config) => { const androidManifest = config.modResults.manifest; if ( !androidManifest.application || androidManifest.application.length === 0 ) { throw new Error("No application found in AndroidManifest.xml"); } const mainApplication = androidManifest.application[0]; (mainApplication as any).profileable = [ { $: { "android:shell": "true", "android:enabled": "true", }, }, ]; return config; }); }; // For e2e builds only (SP_E2E_BUILD=true, `make android-e2e`): trust CAs in // the device's user store, where hack/e2e-local.sh installs the e2e harness's // throwaway CA so the app can reach its HTTPS station and PDS. Never ship // this — it lets whoever can add a user CA intercept the app's traffic. // Cleartext stays off, as in a release build (targetSdk 28+ default). const withE2EUserTrust: ConfigPlugin = (config) => { config = withDangerousMod(config, [ "android", async (config) => { const dir = path.join( config.modRequest.platformProjectRoot, "app/src/main/res/xml", ); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( path.join(dir, "e2e_network_security_config.xml"), ` `, ); return config; }, ]); return withAndroidManifest(config, (config) => { const app = config.modResults.manifest.application?.[0]; if (!app) { throw new Error("No application found in AndroidManifest.xml"); } app.$["android:networkSecurityConfig"] = "@xml/e2e_network_security_config"; return config; }); }; const withConsistentVersionNumber = ( config, { version }: { version: string }, ) => { // if (!config.ios) { // config.ios = {}; // } // if (!config.ios.infoPlist) { // config.ios.infoPlist = {}; // } config = withXcodeProject(config, (config) => { for (let [k, v] of Object.entries( config.modResults.hash.project.objects.XCBuildConfiguration, )) { const obj = v as any; if (!obj.buildSettings) { continue; } if (typeof obj.buildSettings.MARKETING_VERSION !== "undefined") { obj.buildSettings.MARKETING_VERSION = version; } if (typeof obj.buildSettings.CURRENT_PROJECT_VERSION !== "undefined") { obj.buildSettings.CURRENT_PROJECT_VERSION = version; } } return config; }); return config; }; // turn a semver string into a always-increasing integer for google export const versionCode = (verStr: string) => { const [major, minor, patch] = verStr.split(".").map((x) => parseInt(x)); return major * 1000 * 1000 + minor * 1000 + patch; }; export default function () { const isProd = process.env["SP_PRODUCTION_RELEASE"] === "true" || !!process.env.CI; const enableSentry = process.env["SP_ENABLE_SENTRY"] === "true"; const isE2E = process.env["SP_E2E_BUILD"] === "true"; const pkg = require("./package.json"); // Brand imagery and colors are generated from the active brand directory // (see brand/README.md); expo prebuild derives every native icon format // from the generated PNGs. let brand; try { brand = require("./assets/generated/brand.json"); } catch { throw new Error( "Missing generated brand assets — run `pnpm run brand` from the repo root.", ); } const name = isProd ? brand.name : "Devplace"; let bundle = isProd ? "tv.aquareum" : "tv.aquareum.dev"; if (process.env["SP_BUNDLE_OVERRIDE"]) { bundle = process.env["SP_BUNDLE_OVERRIDE"]; } let appleTeamId = process.env["SP_APPLE_TEAM_ID"]; const scheme = process.env["SP_APP_SCHEME"] ?? bundle; return { expo: { name: name, slug: name, version: pkg.version, // Only rev this to the current version when native dependencies change! runtimeVersion: pkg.runtimeVersion, orientation: "default", icon: "./assets/generated/icon.png", scheme: scheme, userInterfaceStyle: "automatic", splash: { image: "./assets/generated/splash.png", resizeMode: "contain", backgroundColor: brand.colors.splashBackground, }, assetBundlePatterns: ["**/*"], ios: { supportsTablet: true, bundleIdentifier: bundle, infoPlist: { UIBackgroundModes: ["fetch", "remote-notification"], LSMinimumSystemVersion: "12.0", }, ...(appleTeamId ? { appleTeamId, } : {}), ...(isProd ? { googleServicesFile: "./GoogleService-Info.plist", entitlements: { "aps-environment": "production", }, associatedDomains: ["applinks:stream.place"], } : {}), }, android: { adaptiveIcon: { foregroundImage: "./assets/generated/adaptive-icon.png", backgroundColor: brand.colors.adaptiveIconBackground, }, package: bundle, edgeToEdgeEnabled: true, versionCode: versionCode(pkg.version), intentFilters: [ { action: "VIEW", autoVerify: true, data: [ { scheme: "https", host: "stream.place", pathPattern: "/.*:.*", }, ], category: ["BROWSABLE", "DEFAULT"], }, { action: "VIEW", autoVerify: true, data: [ { scheme: "https", host: "stream.place", pathPattern: "/.*\\\\..*", }, ], category: ["BROWSABLE", "DEFAULT"], }, { action: "VIEW", autoVerify: true, data: [ { scheme: "https", host: "stream.place", path: "/", }, ], category: ["BROWSABLE", "DEFAULT"], }, ], ...(isProd ? { googleServicesFile: "./google-services.json", permissions: [ "android.permission.SCHEDULE_EXACT_ALARM", "android.permission.POST_NOTIFICATIONS", ], } : {}), }, web: { bundler: "metro", output: "single", favicon: "./assets/generated/favicon.png", }, plugins: [ withAndroidProfileable, "expo-video", "expo-image", "expo-web-browser", "expo-screen-orientation", streamplaceReactNativeWebRTC, [ "expo-video", { supportsBackgroundPlayback: true, supportsPictureInPicture: true, }, ], ["expo-sqlite", { useSQLCipher: true }], "expo-file-system", [ "expo-font", { fonts: [ "./assets/fonts/Geist-Regular.ttf", "./assets/fonts/Geist-Medium.ttf", "./assets/fonts/Geist-SemiBold.ttf", "./assets/fonts/GeistMono-Regular.ttf", "./assets/fonts/GeistMono-Medium.ttf", "./assets/fonts/GeistMono-SemiBold.ttf", ], }, ], [ "expo-build-properties", { ios: { useFrameworks: "static", forceStaticLinking: ["RNFBApp", "RNFBMessaging"], // useful if you can't get forceStaticLinking to work //buildReactNativeFromSource: true, }, // uncomment to test OTA updates to http://localhost:8080 // android: { // usesCleartextTraffic: true, // }, android: { packagingOptions: { pickFirst: ["**/libcrypto.so"], }, }, }, ], [ "expo-asset", { assets: ["assets"], }, ], [withConsistentVersionNumber, { version: pkg.version }], [ "react-native-edge-to-edge", { android: { parentTheme: "Default", enforceNavigationBarContrast: false, }, }, ], ...(isProd ? [ "@react-native-firebase/app", "@react-native-firebase/messaging", [withNotificationsIOS, {}], ] : ["expo-dev-launcher", withoutNotificationsIOS]), ...(enableSentry ? [ [ "@sentry/react-native", { url: "https://sentry.io/", project: process.env["SP_SENTRY_APP"] || "app", organization: process.env["SP_SENTRY_ORG"] || "streamplace", }, ], ] : []), ...(isE2E ? [withE2EUserTrust] : []), ], // e2e builds must run the JS they were built with, not whatever // production update the manifest serves updates: isProd && !isE2E ? { url: `https://stream.place/api/manifest`, enabled: true, checkAutomatically: "ON_LOAD", fallbackToCacheTimeout: 30000, codeSigningCertificate: "./code-signing/certs/certificate.pem", codeSigningMetadata: { keyid: "main", alg: "rsa-v1_5-sha256", }, } : {}, }, }; }