import type { ConfigContext, ExpoConfig } from 'expo/config'; import zh from './locales/zh.json'; import en from './locales/en.json'; type PermissionKey = | 'microphone' | 'photos' | 'camera' | 'microphoneForVideo' | 'faceId'; type LocaleMessages = { permissions?: Partial>; }; type PrivacyAccessedAPIType = { NSPrivacyAccessedAPIType: string; NSPrivacyAccessedAPITypeReasons: string[]; }; const LOCALES: Record = { zh, en, }; const SUPPORTED_LOCALES = ['zh', 'en'] as const; const PRIMARY_LOCALE = process.env.EXPO_PUBLIC_PRIMARY_LOCALE ?? 'zh'; const WEB_SQLITE_HEADERS = { 'Cross-Origin-Embedder-Policy': 'credentialless', 'Cross-Origin-Opener-Policy': 'same-origin', } as const; const PERMISSION_FALLBACKS: Record = { microphone: 'Allow $(PRODUCT_NAME) to access your microphone.', photos: 'Allow $(PRODUCT_NAME) to access your photos.', camera: 'Allow $(PRODUCT_NAME) to access your camera.', microphoneForVideo: 'Allow $(PRODUCT_NAME) to access your microphone.', faceId: 'Allow $(PRODUCT_NAME) to access Face ID.', }; /** * iOS privacy manifest required-reason APIs. * * These values are aggregated from the PrivacyInfo.xcprivacy files shipped by the * Expo native modules currently used in this app, because Apple may not always * correctly parse all static CocoaPods dependency manifests during submission. * * Sources checked in node_modules: * - expo-task-manager * - expo-media-library * - expo-localization * - expo-file-system * - expo-system-ui * - expo-device * - expo-constants */ const REQUIRED_PRIVACY_ACCESSED_API_TYPES: PrivacyAccessedAPIType[] = [ { NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults', NSPrivacyAccessedAPITypeReasons: ['CA92.1'], }, { NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryFileTimestamp', NSPrivacyAccessedAPITypeReasons: ['0A2A.1', '3B52.1'], }, { NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryDiskSpace', NSPrivacyAccessedAPITypeReasons: ['E174.1', '85F4.1'], }, { NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategorySystemBootTime', NSPrivacyAccessedAPITypeReasons: ['35F9.1'], }, ]; const localePermissions = LOCALES[PRIMARY_LOCALE]?.permissions ?? {}; function getPermissionMessage(key: PermissionKey): string { return localePermissions[key] ?? PERMISSION_FALLBACKS[key]; } function mergePrivacyAccessedApiTypes( existing: PrivacyAccessedAPIType[] = [], required: PrivacyAccessedAPIType[] = [], ): PrivacyAccessedAPIType[] { const merged = new Map>(); for (const item of [...existing, ...required]) { const reasons = merged.get(item.NSPrivacyAccessedAPIType) ?? new Set(); for (const reason of item.NSPrivacyAccessedAPITypeReasons) { reasons.add(reason); } merged.set(item.NSPrivacyAccessedAPIType, reasons); } return Array.from(merged.entries()).map(([apiType, reasons]) => ({ NSPrivacyAccessedAPIType: apiType, NSPrivacyAccessedAPITypeReasons: Array.from(reasons), })); } export default ({ config }: ConfigContext): ExpoConfig => { const existingPrivacyAccessedApiTypes = (config?.ios?.privacyManifests ?.NSPrivacyAccessedAPITypes ?? []) as PrivacyAccessedAPIType[]; return { ...config, name: 'app-expo', slug: 'app-expo', version: '1.0.0', orientation: 'portrait', icon: './assets/images/icon.png', scheme: 'appexpo', userInterfaceStyle: 'automatic', ios: { ...config?.ios, icon: './assets/expo.icon', bundleIdentifier: 'com.anonymous.app-expo', config: { usesNonExemptEncryption: false, }, infoPlist: { ...config?.ios?.infoPlist, CFBundleAllowMixedLocalizations: true, }, privacyManifests: { ...config?.ios?.privacyManifests, NSPrivacyAccessedAPITypes: mergePrivacyAccessedApiTypes( existingPrivacyAccessedApiTypes, REQUIRED_PRIVACY_ACCESSED_API_TYPES, ), }, }, android: { ...config?.android, // Reverse-DNS; no hyphens (Android package name rules). Matches iOS bundle id intent. package: 'com.anonymous.appexpo', adaptiveIcon: { backgroundColor: '#E6F4FE', foregroundImage: './assets/images/android-icon-foreground.png', backgroundImage: './assets/images/android-icon-background.png', monochromeImage: './assets/images/android-icon-monochrome.png', }, predictiveBackGestureEnabled: false, }, web: { ...config?.web, bundler: 'metro', output: 'static', favicon: './assets/images/favicon.png', }, plugins: [ // CI/local release: android/app/keystore.properties + store file → release signing; -PversionName/-PversionCode './plugins/withAndroidReleaseSigning', // --- Web: Required for expo-sqlite on web --- // COEP/COOP headers enable SharedArrayBuffer in browsers. // Also configure metro.config.js (wasm + dev server headers). [ 'expo-router', { headers: WEB_SQLITE_HEADERS, }, ], [ 'expo-splash-screen', { backgroundColor: '#208AEF', android: { image: './assets/images/splash-icon.png', imageWidth: 76, }, }, ], [ 'expo-localization', { supportedLocales: { ios: SUPPORTED_LOCALES, android: SUPPORTED_LOCALES, }, }, ], 'expo-asset', [ 'expo-audio', { microphonePermission: getPermissionMessage('microphone'), }, ], [ 'expo-image-picker', { photosPermission: getPermissionMessage('photos'), cameraPermission: getPermissionMessage('camera'), microphonePermission: getPermissionMessage('microphoneForVideo'), }, ], 'expo-image', 'expo-sqlite', [ 'expo-secure-store', { configureAndroidBackup: true, faceIDPermission: getPermissionMessage('faceId'), }, ], 'expo-font', ], locales: { zh: './locales/zh.json', en: './locales/en.json', }, extra: { ...config?.extra, supportsRTL: true, }, experiments: { ...config?.experiments, typedRoutes: true, reactCompiler: true, }, }; };