diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a390c6fa1..22c6be1c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐Ÿ› Bug fixes +- [eas-cli] Fix flavor detection in `build.gradle` files that use string interpolation, such as `buildConfigField "String", "KEY", "\"${System.getenv("KEY")}\""`, which made `eas build` fail with "Failed to autodetect applicationId in multi-flavor project". ([#4260](https://github.com/expo/eas-cli/pull/4260) by [@giaBaoJS](https://github.com/giaBaoJS)) + ### ๐Ÿงน Chores ## [23.2.0](https://github.com/expo/eas-cli/releases/tag/v23.2.0) - 2026-08-31 diff --git a/packages/eas-cli/src/project/android/__tests__/fixtures/string-interpolation-in-build.gradle b/packages/eas-cli/src/project/android/__tests__/fixtures/string-interpolation-in-build.gradle new file mode 100644 index 0000000000..1a8a9b8b6a --- /dev/null +++ b/packages/eas-cli/src/project/android/__tests__/fixtures/string-interpolation-in-build.gradle @@ -0,0 +1,62 @@ +apply plugin: "com.android.application" + +import com.android.build.OutputFile + +def enableProguardInReleaseBuilds = false +def jscFlavor = 'org.webkit:android-jsc:+' + +android { + compileSdkVersion rootProject.ext.compileSdkVersion + + buildFeatures { + buildConfig true + } + + defaultConfig { + applicationId "com.testapp" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + + buildConfigField "String", "API_KEY", "\"${System.getenv("API_KEY")}\"" + buildConfigField "String", "BUILD_LABEL", "\"${System.getenv("FLAVOR")}-${System.getenv("STAGE")}\"" + resValue "string", "app_id", "\"${System.getenv("APP_ID")}\"" + } + flavorDimensions "env" + productFlavors { + staging { + dimension "env" + applicationId "com.testapp.staging" + versionCode 123 + } + production { + dimension "env" + applicationId "com.testapp" + versionCode 124 + } + } + buildTypes { + release { + minifyEnabled enableProguardInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + } + } + + applicationVariants.all { variant -> + variant.outputs.each { output -> + def abi = output.getFilter(OutputFile.ABI) + if (abi != null) { + output.versionCodeOverride = defaultConfig.versionCode + } + } + } +} + +dependencies { + implementation "com.facebook.react:react-native:+" + debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") { + exclude group:'com.facebook.fbjni' + } + implementation jscFlavor +} diff --git a/packages/eas-cli/src/project/android/__tests__/gradleUtils-test.ts b/packages/eas-cli/src/project/android/__tests__/gradleUtils-test.ts index ffcf073902..c235db823b 100644 --- a/packages/eas-cli/src/project/android/__tests__/gradleUtils-test.ts +++ b/packages/eas-cli/src/project/android/__tests__/gradleUtils-test.ts @@ -166,6 +166,35 @@ describe(getAppBuildGradleAsync, () => { }, }); }); + + test('parsing build.gradle with interpolated strings', async () => { + vol.fromJSON( + { + 'android/app/build.gradle': await fsReal.promises.readFile( + path.join(__dirname, 'fixtures/string-interpolation-in-build.gradle'), + 'utf-8' + ), + }, + '/test' + ); + const buildGradle = await getAppBuildGradleAsync('/test'); + expect(pick(buildGradle?.android ?? {}, ['flavorDimensions', 'productFlavors'])).toEqual({ + flavorDimensions: 'env', + productFlavors: { + staging: { + applicationId: 'com.testapp.staging', + versionCode: '123', + dimension: 'env', + }, + production: { + applicationId: 'com.testapp', + versionCode: '124', + dimension: 'env', + }, + }, + }); + expect(buildGradle?.android?.defaultConfig?.applicationId).toBe('com.testapp'); + }); }); describe(parseGradleCommand, () => { diff --git a/packages/eas-cli/src/project/android/gradleUtils.ts b/packages/eas-cli/src/project/android/gradleUtils.ts index 70e8128c88..a54f4bde4e 100644 --- a/packages/eas-cli/src/project/android/gradleUtils.ts +++ b/packages/eas-cli/src/project/android/gradleUtils.ts @@ -45,7 +45,28 @@ export async function getAppBuildGradleAsync(projectDir: string): Promise !line.trim().startsWith('//')) .join('\n'); - return await g2js.parseText(rawBuildGradleWithoutComments); + return await g2js.parseText(unwrapStringInterpolations(rawBuildGradleWithoutComments)); +} + +/** + * gradle-to-js counts `{` and `}` without knowing about string literals, so the braces of a + * Groovy string interpolation are treated as a block. When the interpolation contains a method + * call, like `buildConfigField "String", "KEY", "\"${System.getenv("KEY")}\""`, the parser + * skips one character too many and swallows the closing brace of the surrounding block. Every + * entry that follows then ends up nested in the wrong place, which is why `android.productFlavors` + * comes back as `undefined` for projects that use interpolated build config fields. + * + * Unwrapping the interpolations drops the braces and keeps their content. `[^{}]*` never matches + * across a brace, so nested interpolations are unwrapped one level per pass. + */ +function unwrapStringInterpolations(buildGradle: string): string { + let result = buildGradle; + let previousResult; + do { + previousResult = result; + result = result.replace(/\$\{([^{}]*)\}/g, '$$$1'); + } while (result !== previousResult); + return result; } export function resolveConfigValue(