2026-03-19 01:12:17 +08:00
|
|
|
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<Record<PermissionKey, string>>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type PrivacyAccessedAPIType = {
|
|
|
|
|
NSPrivacyAccessedAPIType: string;
|
|
|
|
|
NSPrivacyAccessedAPITypeReasons: string[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const LOCALES: Record<string, LocaleMessages> = {
|
|
|
|
|
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<PermissionKey, string> = {
|
|
|
|
|
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<string, Set<string>>();
|
|
|
|
|
|
|
|
|
|
for (const item of [...existing, ...required]) {
|
|
|
|
|
const reasons =
|
|
|
|
|
merged.get(item.NSPrivacyAccessedAPIType) ?? new Set<string>();
|
|
|
|
|
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,
|
2026-03-20 16:36:42 +08:00
|
|
|
// Reverse-DNS; no hyphens (Android package name rules). Matches iOS bundle id intent.
|
|
|
|
|
package: 'com.anonymous.appexpo',
|
2026-03-19 01:12:17 +08:00
|
|
|
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: [
|
2026-03-20 17:25:42 +08:00
|
|
|
// CI/local release: android/app/keystore.properties + store file → release signing; -PversionName/-PversionCode
|
|
|
|
|
'./plugins/withAndroidReleaseSigning',
|
2026-03-19 01:12:17 +08:00
|
|
|
// --- 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,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
};
|