import type { ConfigContext, ExpoConfig } from 'expo/config'; import zh from './locales/zh.json'; import en from './locales/en.json'; import permissionsZh from './locales/permissions/zh.json'; import permissionsEn from './locales/permissions/en.json'; type PermissionKey = | 'microphone' | 'photos' | 'camera' | 'microphoneForVideo' | 'faceId'; type LocaleMessages = { permissions?: Partial>; }; type PrivacyAccessedAPIType = { NSPrivacyAccessedAPIType: string; NSPrivacyAccessedAPITypeReasons: string[]; }; const LOCALES: Record = { zh: { ...zh, permissions: permissionsZh }, en: { ...en, permissions: permissionsEn }, }; const SUPPORTED_LOCALES = ['zh', 'en'] as const; const PRIMARY_LOCALE = process.env.EXPO_PUBLIC_PRIMARY_LOCALE ?? 'zh'; 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: 'Life Echo', slug: 'life-echo', version: '1.0.0', orientation: 'portrait', icon: './assets/images/icon.png', scheme: 'lifeecho', userInterfaceStyle: 'automatic', ios: { ...config?.ios, icon: './assets/images/icon.png', 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, /** * `resize` → `adjustResize`:键盘顶起时缩小窗口,聊天输入框随内容上移。 * 与 `KeyboardAvoidingView` + `behavior="height"` 叠用会重复处理,易错位(见 Expo Keyboard 指南)。 */ softwareKeyboardLayoutMode: 'resize', // 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', monochromeImage: './assets/images/android-icon-monochrome.png', }, predictiveBackGestureEnabled: false, }, plugins: [ // CI/local release: android/app/keystore.properties + store file → release signing; -PversionName/-PversionCode './plugins/withAndroidReleaseSigning', 'expo-router', [ 'expo-splash-screen', { // 与 android.adaptiveIcon.backgroundColor、品牌浅紫一致(见 scripts/generate-app-icon.sh) backgroundColor: '#E6F4FE', image: './assets/images/splash-icon.png', resizeMode: 'contain', imageWidth: 200, }, ], [ '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, }, }; };