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[]; }; type ExpoPlugin = NonNullable[number]; 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 API_BASE_URL = process.env.EXPO_PUBLIC_API_URL?.trim() ?? ''; const WS_BASE_URL = process.env.EXPO_PUBLIC_WS_URL?.trim() ?? ''; const GOOGLE_IOS_URL_SCHEME = process.env.EXPO_PUBLIC_GOOGLE_IOS_URL_SCHEME?.trim() ?? ''; if (!API_BASE_URL || !WS_BASE_URL) { throw new Error( '[app.config] Missing EXPO_PUBLIC_API_URL or EXPO_PUBLIC_WS_URL. ' + 'Run `npm run use-env -- ` in app-expo before prebuild or Metro.', ); } const ALLOW_INSECURE_HTTP = API_BASE_URL.startsWith('http://'); const APP_VARIANT = process.env.APP_VARIANT ?? process.env.EXPO_PUBLIC_APP_VARIANT ?? 'development'; const IS_STAGING = APP_VARIANT === 'staging'; const IS_PRODUCTION = APP_VARIANT === 'production'; const IOS_BUNDLE_IDENTIFIER = IS_STAGING ? 'org.brighteng.lifecho.staging' : IS_PRODUCTION ? 'org.brighteng.lifecho' : 'com.anonymous.app-expo'; const ANDROID_PACKAGE = IS_STAGING ? 'org.brighteng.lifecho.staging' : IS_PRODUCTION ? 'org.brighteng.lifecho' : 'com.anonymous.appexpo'; /** Home-screen label; varies by variant. */ const APP_DISPLAY_NAME = IS_STAGING ? 'Life Echo (Staging)' : 'Life Echo'; /** Native Xcode/Android project name — keep stable so ios/LifeEcho.xcworkspace always works. */ const NATIVE_PROJECT_NAME = 'Life Echo'; 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]; } const PACKAGE_VERSION = ( require('./package.json') as { version: string } ).version; /** Local SSOT: package.json. CI overrides via APP_VERSION / APP_BUILD_NUMBER. */ function resolveAppVersion(): string { return process.env.APP_VERSION?.trim() || PACKAGE_VERSION; } function resolveBuildNumber(): string | undefined { const raw = process.env.APP_BUILD_NUMBER?.trim(); return raw || undefined; } function resolveAndroidVersionCode(): number | undefined { const build = resolveBuildNumber(); if (!build) return undefined; const parsed = Number.parseInt(build, 10); return Number.isFinite(parsed) ? parsed : undefined; } 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[]; const appVersion = resolveAppVersion(); const buildNumber = resolveBuildNumber(); const androidVersionCode = resolveAndroidVersionCode(); const googleSignInPlugin: ExpoPlugin[] = GOOGLE_IOS_URL_SCHEME ? [ [ '@react-native-google-signin/google-signin', { iosUrlScheme: GOOGLE_IOS_URL_SCHEME }, ] as ExpoPlugin, ] : []; return { ...config, name: NATIVE_PROJECT_NAME, slug: 'life-echo', version: appVersion, orientation: 'portrait', icon: './assets/images/icon.png', scheme: 'lifeecho', userInterfaceStyle: 'automatic', ios: { ...config?.ios, ...(buildNumber ? { buildNumber } : {}), icon: './assets/images/icon.png', bundleIdentifier: IOS_BUNDLE_IDENTIFIER, config: { usesNonExemptEncryption: false, }, infoPlist: { ...config?.ios?.infoPlist, CFBundleDisplayName: APP_DISPLAY_NAME, CFBundleAllowMixedLocalizations: true, }, privacyManifests: { ...config?.ios?.privacyManifests, NSPrivacyAccessedAPITypes: mergePrivacyAccessedApiTypes( existingPrivacyAccessedApiTypes, REQUIRED_PRIVACY_ACCESSED_API_TYPES, ), }, }, android: { ...config?.android, ...(androidVersionCode != null ? { versionCode: androidVersionCode } : {}), /** * `resize` → `adjustResize`:键盘顶起时缩小窗口,聊天输入框随内容上移。 * 与 `KeyboardAvoidingView` + `behavior="height"` 叠用会重复处理,易错位(见 Expo Keyboard 指南)。 */ softwareKeyboardLayoutMode: 'resize', // Reverse-DNS; no hyphens (Android package name rules). Matches iOS bundle id intent. package: ANDROID_PACKAGE, adaptiveIcon: { backgroundColor: '#E6F4FE', foregroundImage: './assets/images/android-icon-foreground.png', monochromeImage: './assets/images/android-icon-monochrome.png', }, predictiveBackGestureEnabled: false, }, plugins: [ ...googleSignInPlugin, // CI/local release: android/app/keystore.properties + store file → release signing; -PversionName/-PversionCode './plugins/withAndroidReleaseSigning', [ './plugins/withAndroidCleartextTraffic', { enabled: ALLOW_INSECURE_HTTP }, ], [ './plugins/withIosInsecureHttp', { enabled: ALLOW_INSECURE_HTTP, apiUrl: API_BASE_URL, wsUrl: WS_BASE_URL, }, ], 'expo-router', [ 'expo-splash-screen', { // 与 android.adaptiveIcon.backgroundColor、品牌浅紫一致(见 scripts/generate-app-icon.sh,源图为 assets/logo.png) 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, }, }; };